mkslide

init

936ed7e44a0375c337fd0dfab8647bac5748853d

SM <seb.michalk@gmail.com>

2026-04-27 13:13:59 +0000

 README          |  33 ++++++
 build.zig       |  40 +++++++
 src/c.zig       |   8 ++
 src/config.zig  |  24 +++++
 src/mkslide.zig | 323 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 5 files changed, 428 insertions(+)

diff --git a/README b/README
new file mode 100644
index 0000000..ac460ed
--- /dev/null
+++ b/README
@@ -0,0 +1,33 @@
+msklide
+=======
+
+mkslide displays text slides from markdown files.
+
+USAGE
+	msklide file.md
+
+KEYS
+	right, space, enter	next slide
+	left, backspace		previous slide  
+	escape, q		quit
+
+MARKUP SYNTAX
+	# header		slide title
+	text			  slide content
+	
+	blank lines are ignored
+
+DEPENDENCIES
+	xcb
+	cairo
+	xcb-keysyms
+	X11
+
+BUILD
+	zig build-exe slides.zig -lc -lxcb -lcairo -lxcb-keysyms -lX11
+
+INSTALL
+	cp mkslide /usr/local/bin
+
+CONFIGURATION
+	edit config.zig and recompile
diff --git a/build.zig b/build.zig
new file mode 100644
index 0000000..a28d6d0
--- /dev/null
+++ b/build.zig
@@ -0,0 +1,40 @@
+const std = @import("std");
+
+pub fn build(b: *std.Build) void {
+    const target = b.standardTargetOptions(.{});
+    const optimize = b.standardOptimizeOption(.{});
+    const exe = b.addExecutable(.{
+        .name = "mkslide",
+        .root_source_file = b.path("src/mkslide.zig"),
+        .target = target,
+        .optimize = optimize,
+    });
+
+    exe.linkLibC();
+    exe.linkSystemLibrary("xcb");
+    exe.linkSystemLibrary("cairo");
+    exe.linkSystemLibrary("xcb-keysyms");
+    exe.linkSystemLibrary("X11");
+    b.installArtifact(exe);
+
+    const run_cmd = b.addRunArtifact(exe);
+
+    run_cmd.step.dependOn(b.getInstallStep());
+
+    if (b.args) |args| {
+        run_cmd.addArgs(args);
+    }
+
+    const run_step = b.step("run", "Run the app");
+    run_step.dependOn(&run_cmd.step);
+
+    const unit_tests = b.addTest(.{
+        .root_source_file = b.path("src/mkslide.zig"),
+        .target = target,
+        .optimize = optimize,
+    });
+    const run_unit_tests = b.addRunArtifact(unit_tests);
+
+    const test_step = b.step("test", "Run unit tests");
+    test_step.dependOn(&run_unit_tests.step);
+}
diff --git a/src/c.zig b/src/c.zig
new file mode 100644
index 0000000..130bf8b
--- /dev/null
+++ b/src/c.zig
@@ -0,0 +1,8 @@
+pub usingnamespace @cImport({
+    @cInclude("stdlib.h");
+    @cInclude("xcb/xcb.h");
+    @cInclude("xcb/xcb_keysyms.h");
+    @cInclude("cairo/cairo-xcb.h");
+    @cInclude("cairo/cairo.h");
+    @cInclude("X11/keysym.h");
+});
diff --git a/src/config.zig b/src/config.zig
new file mode 100644
index 0000000..5380e52
--- /dev/null
+++ b/src/config.zig
@@ -0,0 +1,24 @@
+// misc
+pub const AUTHOR = "NAME";
+pub const font = "Liberation Mono";
+pub const header_font = "Liberation Mono";
+pub const title = "mkslide";
+
+// sizes
+pub const font_size = 35;
+pub const header_font_size = 40;
+pub const footer_font_size = 20.0;
+pub const win_w = 1920;
+pub const win_h = 1080;
+pub const pad = 60;
+pub const line_gap = 20;
+
+// center
+pub const centerh = true;
+pub const centert = false;
+
+// color
+pub const colors = struct {
+    pub const bg = [3]f64{ 0.24, 0.24, 0.24 };
+    pub const fg = [3]f64{ 0.9, 0.9, 0.9 };
+};
diff --git a/src/mkslide.zig b/src/mkslide.zig
new file mode 100644
index 0000000..b4cf34a
--- /dev/null
+++ b/src/mkslide.zig
@@ -0,0 +1,323 @@
+const std = @import("std");
+const c = @import("c.zig");
+const cfg = @import("config.zig");
+
+const Slide = struct {
+    head: ?[*:0]const u8 = null,
+    lines: std.ArrayList([*:0]const u8),
+
+    fn init(a: std.mem.Allocator) Slide {
+        return Slide{ .lines = std.ArrayList([*:0]const u8).init(a) };
+    }
+
+    fn deinit(self: *Slide) void {
+        self.lines.deinit();
+    }
+};
+
+const Trim = struct {
+    start: usize,
+    len: usize,
+};
+
+const Deck = struct {
+    alloc: std.mem.Allocator,
+    slides: std.ArrayList(Slide),
+    mem: []u8 = &[_]u8{},
+    len: usize = 0,
+    idx: usize = 0,
+
+    fn init(a: std.mem.Allocator) Deck {
+        return Deck{ .alloc = a, .slides = std.ArrayList(Slide).init(a) };
+    }
+
+    fn deinit(self: *Deck) void {
+        self.reset();
+        self.slides.deinit();
+    }
+
+    fn reset(self: *Deck) void {
+        for (self.slides.items) |*s| s.deinit();
+        self.slides.clearRetainingCapacity();
+        self.idx = 0;
+        if (self.mem.len != 0) {
+            self.alloc.free(self.mem);
+            self.mem = &[_]u8{};
+            self.len = 0;
+        }
+    }
+
+    fn load(self: *Deck, path: []const u8) !void {
+        self.reset();
+        var f = try std.fs.cwd().openFile(path, .{});
+        defer f.close();
+        const sz = try f.getEndPos();
+        var buf = try self.alloc.alloc(u8, sz + 1);
+        errdefer {
+            self.alloc.free(buf);
+            self.mem = &[_]u8{};
+            self.len = 0;
+        }
+        const read = try f.readAll(buf[0..sz]);
+        buf[read] = 0;
+        self.mem = buf;
+        self.len = read;
+        try self.parse();
+        if (self.slides.items.len == 0) return error.NoSlides;
+    }
+
+    fn parse(self: *Deck) !void {
+        var sl = Slide.init(self.alloc);
+        var have = false;
+        var start: usize = 0;
+        var i: usize = 0;
+
+        while (i <= self.len) : (i += 1) {
+            if (i == self.len or self.mem[i] == '\n') {
+                if (trim_line(self.mem, start, i)) |trim| {
+                    const line_ptr = @as([*:0]const u8, @ptrCast(self.mem.ptr + trim.start));
+                    if (self.mem[trim.start] == '#') {
+                        if (have) {
+                            try self.slides.append(sl);
+                            sl = Slide.init(self.alloc);
+                            have = false;
+                        }
+                        var head_start = trim.start + 1;
+                        if (head_start - trim.start >= trim.len) {
+                            start = i + 1;
+                            continue;
+                        }
+                        var head_len = trim.len - 1;
+                        while (head_len > 0 and is_space(self.mem[head_start])) {
+                            head_start += 1;
+                            head_len -= 1;
+                        }
+                        if (head_len == 0) {
+                            start = i + 1;
+                            continue;
+                        }
+                        sl.head = @as([*:0]const u8, @ptrCast(self.mem.ptr + head_start));
+                        have = true;
+                    } else {
+                        try sl.lines.append(line_ptr);
+                        have = true;
+                    }
+                }
+                start = i + 1;
+            }
+        }
+
+        if (have) {
+            try self.slides.append(sl);
+        } else {
+            sl.deinit();
+        }
+    }
+
+    fn next(self: *Deck) void {
+        if (self.idx + 1 < self.slides.items.len) self.idx += 1;
+    }
+
+    fn prev(self: *Deck) void {
+        if (self.idx > 0) self.idx -= 1;
+    }
+
+    fn peek(self: *Deck) ?*const Slide {
+        if (self.slides.items.len == 0) return null;
+        return &self.slides.items[self.idx];
+    }
+};
+
+const State = struct {
+    conn: *c.xcb_connection_t,
+    win: c.xcb_window_t,
+    surf: *c.cairo_surface_t,
+    cr: *c.cairo_t,
+    keys: *c.xcb_key_symbols_t,
+    deck: Deck,
+
+    fn init(a: std.mem.Allocator) !State {
+        var scr_no: c_int = undefined;
+        const conn = c.xcb_connect(null, &scr_no);
+        if (conn == null or c.xcb_connection_has_error(conn) != 0) {
+            return error.ConnectionFailed;
+        }
+
+        const setup = c.xcb_get_setup(conn);
+        var it = c.xcb_setup_roots_iterator(setup);
+        var n: c_int = 0;
+        while (n < scr_no) : (n += 1) c.xcb_screen_next(&it);
+        const scr = it.data;
+        const win = c.xcb_generate_id(conn);
+        const mask = c.XCB_CW_BACK_PIXEL | c.XCB_CW_EVENT_MASK;
+        const vals = [_]u32{
+            scr.*.white_pixel,
+            c.XCB_EVENT_MASK_EXPOSURE | c.XCB_EVENT_MASK_KEY_PRESS,
+        };
+        _ = c.xcb_create_window(
+            conn,
+            c.XCB_COPY_FROM_PARENT,
+            win,
+            scr.*.root,
+            0,
+            0,
+            cfg.win_w,
+            cfg.win_h,
+            0,
+            c.XCB_WINDOW_CLASS_INPUT_OUTPUT,
+            scr.*.root_visual,
+            mask,
+            &vals,
+        );
+        _ = c.xcb_change_property(
+            conn,
+            c.XCB_PROP_MODE_REPLACE,
+            win,
+            c.XCB_ATOM_WM_NAME,
+            c.XCB_ATOM_STRING,
+            8,
+            cfg.title.len,
+            cfg.title,
+        );
+        const visual = find_visual(scr, scr.*.root_visual) orelse return error.VisualNotFound;
+        const surf_opt = c.cairo_xcb_surface_create(conn, win, visual, cfg.win_w, cfg.win_h);
+        if (surf_opt == null) return error.CairoSurface;
+        const surf = surf_opt.?;
+        const cr_opt = c.cairo_create(surf);
+        if (cr_opt == null) return error.CairoContext;
+        const cr = cr_opt.?;
+        const keys_opt = c.xcb_key_symbols_alloc(conn);
+        if (keys_opt == null) return error.KeySyms;
+        const keys = keys_opt.?;
+        _ = c.xcb_map_window(conn, win);
+        _ = c.xcb_flush(conn);
+        return State{
+            .conn = conn.?,
+            .win = win,
+            .surf = surf,
+            .cr = cr,
+            .keys = keys,
+            .deck = Deck.init(a),
+        };
+    }
+
+    fn deinit(self: *State) void {
+        self.deck.deinit();
+        c.cairo_destroy(self.cr);
+        c.cairo_surface_destroy(self.surf);
+        c.xcb_key_symbols_free(self.keys);
+        c.xcb_disconnect(self.conn);
+    }
+
+    fn draw(self: *State) void {
+        c.cairo_set_source_rgb(self.cr, cfg.colors.bg[0], cfg.colors.bg[1], cfg.colors.bg[2]);
+        c.cairo_paint(self.cr);
+        const slide = self.deck.peek() orelse return;
+        var y: f64 = cfg.pad;
+        var te: c.cairo_text_extents_t = undefined;
+        if (slide.head) |head| {
+            c.cairo_select_font_face(self.cr, cfg.header_font, c.CAIRO_FONT_SLANT_NORMAL, c.CAIRO_FONT_WEIGHT_BOLD);
+            c.cairo_set_font_size(self.cr, cfg.header_font_size);
+            c.cairo_set_source_rgb(self.cr, cfg.colors.fg[0], cfg.colors.fg[1], cfg.colors.fg[2]);
+            var fe: c.cairo_font_extents_t = undefined;
+            c.cairo_font_extents(self.cr, &fe);
+            y += (fe.ascent + fe.descent) / 2;
+            const x = if (cfg.centerh) blk: {
+                c.cairo_text_extents(self.cr, head, &te);
+                break :blk (cfg.win_w - te.width) / 2;
+            } else cfg.pad;
+            c.cairo_move_to(self.cr, x, y);
+            c.cairo_show_text(self.cr, head);
+            y += fe.descent + cfg.line_gap * 2;
+        }
+        c.cairo_select_font_face(self.cr, cfg.font, c.CAIRO_FONT_SLANT_NORMAL, c.CAIRO_FONT_WEIGHT_NORMAL);
+        c.cairo_set_font_size(self.cr, cfg.font_size);
+        var fe_body: c.cairo_font_extents_t = undefined;
+        c.cairo_font_extents(self.cr, &fe_body);
+        for (slide.lines.items) |ln| {
+            y += fe_body.ascent;
+            c.cairo_move_to(self.cr, if (cfg.centert) (cfg.winert - te.width) / 2 else cfg.pad, y);
+            c.cairo_show_text(self.cr, ln);
+            y += fe_body.descent + cfg.line_gap;
+        }
+        var info_buf: [32:0]u8 = undefined;
+        const info = std.fmt.bufPrintZ(&info_buf, "{}/{}", .{ self.deck.idx + 1, self.deck.slides.items.len }) catch return;
+        c.cairo_select_font_face(self.cr, cfg.font, c.CAIRO_FONT_SLANT_NORMAL, c.CAIRO_FONT_WEIGHT_NORMAL);
+        c.cairo_set_font_size(self.cr, cfg.footer_font_size);
+        c.cairo_text_extents(self.cr, info.ptr, &te);
+        c.cairo_move_to(self.cr, (cfg.win_w - te.width) / 2, cfg.win_h - cfg.pad);
+        c.cairo_show_text(self.cr, info.ptr);
+        c.cairo_move_to(self.cr, cfg.pad, cfg.win_h - cfg.pad);
+        c.cairo_show_text(self.cr, cfg.AUTHOR);
+        c.cairo_surface_flush(self.surf);
+        _ = c.xcb_flush(self.conn);
+    }
+
+    fn key(self: *State, code: c.xcb_keycode_t) bool {
+        const sym = c.xcb_key_symbols_get_keysym(self.keys, code, 0);
+        switch (sym) {
+            c.XK_Escape, c.XK_q => return false,
+            c.XK_Right, c.XK_space, c.XK_Return => self.deck.next(),
+            c.XK_Left, c.XK_BackSpace => self.deck.prev(),
+            else => {},
+        }
+        return true;
+    }
+};
+
+fn die(comptime fmt: []const u8, args: anytype) noreturn {
+    std.debug.print("state: " ++ fmt ++ "\n", args);
+    std.process.exit(1);
+}
+
+fn find_visual(scr: *c.xcb_screen_t, id: c.xcb_visualid_t) ?*c.xcb_visualtype_t {
+    var d = c.xcb_screen_allowed_depths_iterator(scr);
+    while (d.rem != 0) : (c.xcb_depth_next(&d)) {
+        var v = c.xcb_depth_visuals_iterator(d.data);
+        while (v.rem != 0) : (c.xcb_visualtype_next(&v)) {
+            const visual = @as(*c.xcb_visualtype_t, @ptrCast(v.data));
+            if (visual.visual_id == id) return visual;
+        }
+    }
+    return null;
+}
+
+fn trim_line(buf: []u8, start: usize, end: usize) ?Trim {
+    if (start >= end) return null;
+    var s = start;
+    while (s < end and is_space(buf[s])) s += 1;
+    var e = end;
+    while (e > s and is_space(buf[e - 1])) e -= 1;
+    if (s == e) return null;
+    buf[e] = 0;
+    return Trim{ .start = s, .len = e - s };
+}
+
+inline fn is_space(cu: u8) bool {
+    return cu == ' ' or cu == '\t' or cu == '\r';
+}
+
+pub fn main() void {
+    const a = std.heap.page_allocator;
+    const args = std.process.argsAlloc(a) catch die("args", .{});
+    defer std.process.argsFree(a, args);
+    if (args.len < 2) die("usage: {s} <file>", .{args[0]});
+    var state = State.init(a) catch |err| die("init: {}", .{err});
+    defer state.deinit();
+    state.deck.load(args[1]) catch |err| die("slides: {}", .{err});
+    state.draw();
+    while (true) {
+        const ev = c.xcb_wait_for_event(state.conn);
+        if (ev == null) continue;
+        defer c.free(ev);
+        switch (ev.*.response_type & ~@as(u8, 0x80)) {
+            c.XCB_EXPOSE => state.draw(),
+            c.XCB_KEY_PRESS => {
+                const key = @as(*c.xcb_key_press_event_t, @ptrCast(ev));
+                if (!state.key(key.detail)) break;
+                state.draw();
+            },
+            else => {},
+        }
+    }
+}