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 => {}, + } + } +}