diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c26d4af --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +zig-out +zig-cache diff --git a/build.zig b/build.zig new file mode 100644 index 0000000..c9d7cc7 --- /dev/null +++ b/build.zig @@ -0,0 +1,91 @@ +const std = @import("std"); + +// Although this function looks imperative, note that its job is to +// declaratively construct a build graph that will be executed by an external +// runner. +pub fn build(b: *std.Build) void { + // Standard target options allows the person running `zig build` to choose + // what target to build for. Here we do not override the defaults, which + // means any target is allowed, and the default is native. Other options + // for restricting supported target set are available. + const target = b.standardTargetOptions(.{}); + + // Standard optimization options allow the person running `zig build` to select + // between Debug, ReleaseSafe, ReleaseFast, and ReleaseSmall. Here we do not + // set a preferred release mode, allowing the user to decide how to optimize. + const optimize = b.standardOptimizeOption(.{}); + + const lib = b.addStaticLibrary(.{ + .name = "zpinner", + // In this case the main source file is merely a path, however, in more + // complicated build scripts, this could be a generated file. + .root_source_file = b.path("src/zpinner.zig"), + .target = target, + .optimize = optimize, + }); + + // This declares intent for the library to be installed into the standard + // location when the user invokes the "install" step (the default step when + // running `zig build`). + b.installArtifact(lib); + + const exe = b.addExecutable(.{ + .name = "zpinner", + .root_source_file = b.path("src/main.zig"), + .target = target, + .optimize = optimize, + }); + + // This declares intent for the executable to be installed into the + // standard location when the user invokes the "install" step (the default + // step when running `zig build`). + b.installArtifact(exe); + + // This *creates* a Run step in the build graph, to be executed when another + // step is evaluated that depends on it. The next line below will establish + // such a dependency. + const run_cmd = b.addRunArtifact(exe); + + // By making the run step depend on the install step, it will be run from the + // installation directory rather than directly from within the cache directory. + // This is not necessary, however, if the application depends on other installed + // files, this ensures they will be present and in the expected location. + run_cmd.step.dependOn(b.getInstallStep()); + + // This allows the user to pass arguments to the application in the build + // command itself, like this: `zig build run -- arg1 arg2 etc` + if (b.args) |args| { + run_cmd.addArgs(args); + } + + // This creates a build step. It will be visible in the `zig build --help` menu, + // and can be selected like this: `zig build run` + // This will evaluate the `run` step rather than the default, which is "install". + const run_step = b.step("run", "Run the app"); + run_step.dependOn(&run_cmd.step); + + // Creates a step for unit testing. This only builds the test executable + // but does not run it. + const lib_unit_tests = b.addTest(.{ + .root_source_file = b.path("src/root.zig"), + .target = target, + .optimize = optimize, + }); + + const run_lib_unit_tests = b.addRunArtifact(lib_unit_tests); + + const exe_unit_tests = b.addTest(.{ + .root_source_file = b.path("src/main.zig"), + .target = target, + .optimize = optimize, + }); + + const run_exe_unit_tests = b.addRunArtifact(exe_unit_tests); + + // Similar to creating the run step earlier, this exposes a `test` step to + // the `zig build --help` menu, providing a way for the user to request + // running the unit tests. + const test_step = b.step("test", "Run unit tests"); + test_step.dependOn(&run_lib_unit_tests.step); + test_step.dependOn(&run_exe_unit_tests.step); +} diff --git a/build.zig.zon b/build.zig.zon new file mode 100644 index 0000000..f718bf8 --- /dev/null +++ b/build.zig.zon @@ -0,0 +1,31 @@ +.{ + .name = "zpinner", + // This is a [Semantic Version](https://semver.org/). + // In a future version of Zig it will be used for package deduplication. + .version = "0.0.0", + + // This field is optional. + // This is currently advisory only; Zig does not yet do anything + // with this value. + //.minimum_zig_version = "0.11.0", + + // This field is optional. + // Each dependency must either provide a `url` and `hash`, or a `path`. + // `zig build --fetch` can be used to fetch all dependencies of a package, recursively. + // Once all dependencies are fetched, `zig build` no longer requires + // internet connectivity. + .dependencies = .{}, + .paths = .{ + // This makes *all* files, recursively, included in this package. It is generally + // better to explicitly list the files and directories instead, to insure that + // fetching from tarballs, file system paths, and version control all result + // in the same contents hash. + "", + // For example... + //"build.zig", + //"build.zig.zon", + //"src", + //"LICENSE", + //"README.md", + }, +} diff --git a/src/ansi.zig b/src/ansi.zig new file mode 100644 index 0000000..2874491 --- /dev/null +++ b/src/ansi.zig @@ -0,0 +1,3 @@ +pub const Style = @import("style.zig").Style; +pub const Color = @import("style.zig").Color; +pub const format = @import("format.zig"); diff --git a/src/characters.zig b/src/characters.zig new file mode 100644 index 0000000..e29eeaf --- /dev/null +++ b/src/characters.zig @@ -0,0 +1,4 @@ +pub const Moon = "🌑 🌒 🌓 🌔 🌕 🌖 🌗 🌘"; +pub const Snake = "⠋ ⠙ ⠹ ⠸ ⠼ ⠴ ⠦ ⠧ ⠇ ⠏"; +pub const SnakeLoad = "⣾ ⣽ ⣻ ⢿ ⡿ ⣟ ⣯ ⣷"; +pub const Earth = "🌍 🌎 🌏"; diff --git a/src/cursor.zig b/src/cursor.zig new file mode 100644 index 0000000..fbbd2ef --- /dev/null +++ b/src/cursor.zig @@ -0,0 +1,103 @@ +const std = @import("std"); +const testing = std.testing; +const fixedBufferStream = std.io.fixedBufferStream; +const esc = "\x1B"; +const csi = esc ++ "["; + +pub const CursorMode = enum(u8) { + blinking_block = 1, + block, + blinking_underscore, + underscore, + blinking_I_beam, + I_beam, +}; + +pub fn setCursorMode(writer: anytype, mode: CursorMode) !void { + const modeNumber = @intFromEnum(mode); + try writer.print(csi ++ "{d} q", .{modeNumber}); +} + +pub fn hideCursor(writer: anytype) !void { + try writer.writeAll(csi ++ "?25l"); +} + +pub fn showCursor(writer: anytype) !void { + try writer.writeAll(csi ++ "?25h"); +} + +pub fn saveCursor(writer: anytype) !void { + try writer.writeAll(csi ++ "s"); +} + +pub fn restoreCursor(writer: anytype) !void { + try writer.writeAll(csi ++ "u"); +} + +pub fn setCursor(writer: anytype, x: usize, y: usize) !void { + try writer.print(csi ++ "{};{}H", .{ y + 1, x + 1 }); +} + +pub fn setCursorRow(writer: anytype, row: usize) !void { + try writer.print(csi ++ "{}H", .{row + 1}); +} + +pub fn setCursorColumn(writer: anytype, column: usize) !void { + try writer.print(csi ++ "{}G", .{column + 1}); +} + +pub fn cursorUp(writer: anytype, lines: usize) !void { + try writer.print(csi ++ "{}A", .{lines}); +} + +pub fn cursorDown(writer: anytype, lines: usize) !void { + try writer.print(csi ++ "{}B", .{lines}); +} + +pub fn cursorForward(writer: anytype, columns: usize) !void { + try writer.print(csi ++ "{}C", .{columns}); +} + +pub fn cursorBackward(writer: anytype, columns: usize) !void { + try writer.print(csi ++ "{}D", .{columns}); +} + +pub fn cursorNextLine(writer: anytype, lines: usize) !void { + try writer.print(csi ++ "{}E", .{lines}); +} + +pub fn cursorPreviousLine(writer: anytype, lines: usize) !void { + try writer.print(csi ++ "{}F", .{lines}); +} + +pub fn scrollUp(writer: anytype, lines: usize) !void { + try writer.print(csi ++ "{}S", .{lines}); +} + +pub fn scrollDown(writer: anytype, lines: usize) !void { + try writer.print(csi ++ "{}T", .{lines}); +} + +test "test cursor mode BLINKING_UNDERSCORE" { + var buf: [1024]u8 = undefined; + var fixed_buf_stream = fixedBufferStream(&buf); + + try setCursorMode(fixed_buf_stream.writer(), .blinking_underscore); + // the space is needed + const expected = csi ++ "3 q"; + const actual = fixed_buf_stream.getWritten(); + + try testing.expectEqualSlices(u8, expected, actual); +} + +test "test cursor mode BLINKING_I_BEAM" { + var buf: [1024]u8 = undefined; + var fixed_buf_stream = fixedBufferStream(&buf); + + try setCursorMode(fixed_buf_stream.writer(), .blinking_I_beam); + // the space is needed + const expected = csi ++ "5 q"; + const actual = fixed_buf_stream.getWritten(); + + try testing.expectEqualSlices(u8, expected, actual); +} diff --git a/src/format.zig b/src/format.zig new file mode 100644 index 0000000..e11e032 --- /dev/null +++ b/src/format.zig @@ -0,0 +1,304 @@ +const std = @import("std"); +const fixedBufferStream = std.io.fixedBufferStream; +const testing = std.testing; + +const style = @import("style.zig"); +const Style = style.Style; +const FontStyle = style.FontStyle; +const Color = style.Color; + +const esc = "\x1B"; +const csi = esc ++ "["; + +const reset = csi ++ "0m"; + +const font_style_codes = std.ComptimeStringMap([]const u8, .{ + .{ "bold", "1" }, + .{ "dim", "2" }, + .{ "italic", "3" }, + .{ "underline", "4" }, + .{ "slowblink", "5" }, + .{ "rapidblink", "6" }, + .{ "reverse", "7" }, + .{ "hidden", "8" }, + .{ "crossedout", "9" }, + .{ "fraktur", "20" }, + .{ "overline", "53" }, +}); + +/// Update the current style of the ANSI terminal +/// +/// Optionally accepts the previous style active on the +/// terminal. Using this information, the function will update only +/// the attributes which are new in order to minimize the amount +/// written. +/// +/// Tries to use as little bytes as necessary. Use this function if +/// you want to optimize for smallest amount of transmitted bytes +/// instead of computation speed. +pub fn updateStyle(writer: anytype, new: Style, old: ?Style) !void { + if (old) |sty| if (new.eql(sty)) return; + if (new.isDefault()) return try resetStyle(writer); + + // A reset is required if the new font style has attributes not + // present in the old style or if the old style is not known + const reset_required = if (old) |sty| !sty.font_style.subsetOf(new.font_style) else true; + if (reset_required) try resetStyle(writer); + + // Start the escape sequence + try writer.writeAll(csi); + var written_something = false; + + // Font styles + const write_styles = if (reset_required) new.font_style else new.font_style.without(old.?.font_style); + inline for (std.meta.fields(FontStyle)) |field| { + if (@field(write_styles, field.name)) { + const code = font_style_codes.get(field.name).?; + if (written_something) { + try writer.writeAll(";"); + } else { + written_something = true; + } + try writer.writeAll(code); + } + } + + // Foreground color + if (reset_required and new.foreground != .Default or old != null and !old.?.foreground.eql(new.foreground)) { + if (written_something) { + try writer.writeAll(";"); + } else { + written_something = true; + } + + switch (new.foreground) { + .Default => try writer.writeAll("39"), + .Black => try writer.writeAll("30"), + .Red => try writer.writeAll("31"), + .Green => try writer.writeAll("32"), + .Yellow => try writer.writeAll("33"), + .Blue => try writer.writeAll("34"), + .Magenta => try writer.writeAll("35"), + .Cyan => try writer.writeAll("36"), + .White => try writer.writeAll("37"), + .Fixed => |fixed| try writer.print("38;5;{}", .{fixed}), + .Grey => |grey| try writer.print("38;2;{};{};{}", .{ grey, grey, grey }), + .RGB => |rgb| try writer.print("38;2;{};{};{}", .{ rgb.r, rgb.g, rgb.b }), + } + } + + // Background color + if (reset_required and new.background != .Default or old != null and !old.?.background.eql(new.background)) { + if (written_something) { + try writer.writeAll(";"); + } else { + written_something = true; + } + + switch (new.background) { + .Default => try writer.writeAll("49"), + .Black => try writer.writeAll("40"), + .Red => try writer.writeAll("41"), + .Green => try writer.writeAll("42"), + .Yellow => try writer.writeAll("43"), + .Blue => try writer.writeAll("44"), + .Magenta => try writer.writeAll("45"), + .Cyan => try writer.writeAll("46"), + .White => try writer.writeAll("47"), + .Fixed => |fixed| try writer.print("48;5;{}", .{fixed}), + .Grey => |grey| try writer.print("48;2;{};{};{}", .{ grey, grey, grey }), + .RGB => |rgb| try writer.print("48;2;{};{};{}", .{ rgb.r, rgb.g, rgb.b }), + } + } + + // End the escape sequence + try writer.writeAll("m"); +} + +test "same style default, no update" { + var buf: [1024]u8 = undefined; + var fixed_buf_stream = fixedBufferStream(&buf); + + try updateStyle(fixed_buf_stream.writer(), Style{}, Style{}); + + const expected = ""; + const actual = fixed_buf_stream.getWritten(); + + try testing.expectEqualSlices(u8, expected, actual); +} + +test "same style non-default, no update" { + var buf: [1024]u8 = undefined; + var fixed_buf_stream = fixedBufferStream(&buf); + + const sty = Style{ + .foreground = Color.Green, + }; + try updateStyle(fixed_buf_stream.writer(), sty, sty); + + const expected = ""; + const actual = fixed_buf_stream.getWritten(); + + try testing.expectEqualSlices(u8, expected, actual); +} + +test "reset to default, old null" { + var buf: [1024]u8 = undefined; + var fixed_buf_stream = fixedBufferStream(&buf); + + try updateStyle(fixed_buf_stream.writer(), Style{}, null); + + const expected = "\x1B[0m"; + const actual = fixed_buf_stream.getWritten(); + + try testing.expectEqualSlices(u8, expected, actual); +} + +test "reset to default, old non-null" { + var buf: [1024]u8 = undefined; + var fixed_buf_stream = fixedBufferStream(&buf); + + try updateStyle(fixed_buf_stream.writer(), Style{}, Style{ + .font_style = FontStyle.bold, + }); + + const expected = "\x1B[0m"; + const actual = fixed_buf_stream.getWritten(); + + try testing.expectEqualSlices(u8, expected, actual); +} + +test "bold style" { + var buf: [1024]u8 = undefined; + var fixed_buf_stream = fixedBufferStream(&buf); + + try updateStyle(fixed_buf_stream.writer(), Style{ + .font_style = FontStyle.bold, + }, Style{}); + + const expected = "\x1B[1m"; + const actual = fixed_buf_stream.getWritten(); + + try testing.expectEqualSlices(u8, expected, actual); +} + +test "add bold style" { + var buf: [1024]u8 = undefined; + var fixed_buf_stream = fixedBufferStream(&buf); + + try updateStyle(fixed_buf_stream.writer(), Style{ + .font_style = FontStyle{ .bold = true, .italic = true }, + }, Style{ + .font_style = FontStyle.italic, + }); + + const expected = "\x1B[1m"; + const actual = fixed_buf_stream.getWritten(); + + try testing.expectEqualSlices(u8, expected, actual); +} + +test "reset required font style" { + var buf: [1024]u8 = undefined; + var fixed_buf_stream = fixedBufferStream(&buf); + + try updateStyle(fixed_buf_stream.writer(), Style{ + .font_style = FontStyle.bold, + }, Style{ + .font_style = FontStyle{ .bold = true, .underline = true }, + }); + + const expected = "\x1B[0m\x1B[1m"; + const actual = fixed_buf_stream.getWritten(); + + try testing.expectEqualSlices(u8, expected, actual); +} + +test "reset required color style" { + var buf: [1024]u8 = undefined; + var fixed_buf_stream = fixedBufferStream(&buf); + + try updateStyle(fixed_buf_stream.writer(), Style{ + .foreground = Color.Red, + }, null); + + const expected = "\x1B[0m\x1B[31m"; + const actual = fixed_buf_stream.getWritten(); + + try testing.expectEqualSlices(u8, expected, actual); +} + +test "no reset required color style" { + var buf: [1024]u8 = undefined; + var fixed_buf_stream = fixedBufferStream(&buf); + + try updateStyle(fixed_buf_stream.writer(), Style{ + .foreground = Color.Red, + }, Style{}); + + const expected = "\x1B[31m"; + const actual = fixed_buf_stream.getWritten(); + + try testing.expectEqualSlices(u8, expected, actual); +} + +test "no reset required add color style" { + var buf: [1024]u8 = undefined; + var fixed_buf_stream = fixedBufferStream(&buf); + + try updateStyle(fixed_buf_stream.writer(), Style{ + .foreground = Color.Red, + .background = Color.Magenta, + }, Style{ + .background = Color.Magenta, + }); + + const expected = "\x1B[31m"; + const actual = fixed_buf_stream.getWritten(); + + try testing.expectEqualSlices(u8, expected, actual); +} + +pub fn resetStyle(writer: anytype) !void { + try writer.writeAll(reset); +} + +test "reset style" { + var buf: [1024]u8 = undefined; + var fixed_buf_stream = fixedBufferStream(&buf); + + try resetStyle(fixed_buf_stream.writer()); + + const expected = "\x1B[0m"; + const actual = fixed_buf_stream.getWritten(); + + try testing.expectEqualSlices(u8, expected, actual); +} + +test "Grey foreground color" { + var buf: [1024]u8 = undefined; + var fixed_buf_stream = fixedBufferStream(&buf); + var new_style = Style{}; + new_style.foreground = Color{ .Grey = 1 }; + + try updateStyle(fixed_buf_stream.writer(), new_style, Style{}); + + const expected = "\x1B[38;2;1;1;1m"; + const actual = fixed_buf_stream.getWritten(); + + try testing.expectEqualSlices(u8, expected, actual); +} + +test "Grey background color" { + var buf: [1024]u8 = undefined; + var fixed_buf_stream = fixedBufferStream(&buf); + var new_style = Style{}; + new_style.background = Color{ .Grey = 1 }; + + try updateStyle(fixed_buf_stream.writer(), new_style, Style{}); + + const expected = "\x1B[48;2;1;1;1m"; + const actual = fixed_buf_stream.getWritten(); + + try testing.expectEqualSlices(u8, expected, actual); +} diff --git a/src/main.zig b/src/main.zig new file mode 100644 index 0000000..6ac7c6c --- /dev/null +++ b/src/main.zig @@ -0,0 +1,36 @@ +const std = @import("std"); +const zpinner = @import("zpinner.zig"); + +pub fn main() !void { + std.debug.print("All your {s} are belong to us.\n", .{"codebase"}); + + const stdout_file = std.io.getStdOut().writer(); + + const zpinneropts = zpinner.Options{ + .suffix = " - counting sheeps", + .chars = zpinner.chars.Earth, + .style = zpinner.Style{ + .foreground = .Green, + .background = .Black, + }, + }; + + var zpin = zpinner.New(stdout_file.any(), zpinneropts); + try zpin.start(); + std.time.sleep(5 * std.time.ns_per_s); + try zpin.stop(); + + zpin.set_suffix(" - counting crows"); + + std.debug.print("All my codebases are belong to you\n", .{}); + try zpin.start(); + std.time.sleep(5 * std.time.ns_per_s); + try zpin.stop(); +} + +test "simple test" { + var list = std.ArrayList(i32).init(std.testing.allocator); + defer list.deinit(); // try commenting this out and see if zig detects the memory leak! + try list.append(42); + try std.testing.expectEqual(@as(i32, 42), list.pop()); +} diff --git a/src/parse_style.zig b/src/parse_style.zig new file mode 100644 index 0000000..b922c82 --- /dev/null +++ b/src/parse_style.zig @@ -0,0 +1,209 @@ +const std = @import("std"); +const expect = std.testing.expect; +const expectEqual = std.testing.expectEqual; + +const style = @import("style.zig"); +const Style = style.Style; +const FontStyle = style.FontStyle; +const Color = style.Color; + +const ParseState = enum { + parse_8, + parse_fg_non_8, + parse_fg_256, + parse_fg_red, + parse_fg_green, + parse_fg_blue, + parse_bg_non_8, + parse_bg_256, + parse_bg_red, + parse_bg_green, + parse_bg_blue, +}; + +/// Parses an ANSI escape sequence into a Style. Returns null when the +/// string does not represent a valid style description +pub fn parseStyle(code: []const u8) ?Style { + if (code.len == 0 or std.mem.eql(u8, code, "0") or std.mem.eql(u8, code, "00")) { + return null; + } + + var font_style = FontStyle{}; + var foreground: Color = .Default; + var background: Color = .Default; + + var state = ParseState.parse_8; + var red: u8 = 0; + var green: u8 = 0; + + var iter = std.mem.split(u8, code, ";"); + while (iter.next()) |str| { + const part = std.fmt.parseInt(u8, str, 10) catch return null; + + switch (state) { + .parse_8 => { + switch (part) { + 0 => font_style = FontStyle{}, + 1 => font_style.bold = true, + 2 => font_style.dim = true, + 3 => font_style.italic = true, + 4 => font_style.underline = true, + 5 => font_style.slowblink = true, + 6 => font_style.rapidblink = true, + 7 => font_style.reverse = true, + 8 => font_style.hidden = true, + 9 => font_style.crossedout = true, + 20 => font_style.fraktur = true, + 30 => foreground = Color.Black, + 31 => foreground = Color.Red, + 32 => foreground = Color.Green, + 33 => foreground = Color.Yellow, + 34 => foreground = Color.Blue, + 35 => foreground = Color.Magenta, + 36 => foreground = Color.Cyan, + 37 => foreground = Color.White, + 38 => state = ParseState.parse_fg_non_8, + 39 => foreground = Color.Default, + 40 => background = Color.Black, + 41 => background = Color.Red, + 42 => background = Color.Green, + 43 => background = Color.Yellow, + 44 => background = Color.Blue, + 45 => background = Color.Magenta, + 46 => background = Color.Cyan, + 47 => background = Color.White, + 48 => state = ParseState.parse_bg_non_8, + 49 => background = Color.Default, + 53 => font_style.overline = true, + else => { + return null; + }, + } + }, + .parse_fg_non_8 => { + switch (part) { + 5 => state = ParseState.parse_fg_256, + 2 => state = ParseState.parse_fg_red, + else => { + return null; + }, + } + }, + .parse_fg_256 => { + foreground = Color{ .Fixed = part }; + state = ParseState.parse_8; + }, + .parse_fg_red => { + red = part; + state = ParseState.parse_fg_green; + }, + .parse_fg_green => { + green = part; + state = ParseState.parse_fg_blue; + }, + .parse_fg_blue => { + foreground = Color{ + .RGB = .{ + .r = red, + .g = green, + .b = part, + }, + }; + state = ParseState.parse_8; + }, + .parse_bg_non_8 => { + switch (part) { + 5 => state = ParseState.parse_bg_256, + 2 => state = ParseState.parse_bg_red, + else => { + return null; + }, + } + }, + .parse_bg_256 => { + background = Color{ .Fixed = part }; + state = ParseState.parse_8; + }, + .parse_bg_red => { + red = part; + state = ParseState.parse_bg_green; + }, + .parse_bg_green => { + green = part; + state = ParseState.parse_bg_blue; + }, + .parse_bg_blue => { + background = Color{ + .RGB = .{ + .r = red, + .g = green, + .b = part, + }, + }; + state = ParseState.parse_8; + }, + } + } + + if (state != ParseState.parse_8) + return null; + + return Style{ + .foreground = foreground, + .background = background, + .font_style = font_style, + }; +} + +test "parse empty style" { + try expectEqual(@as(?Style, null), parseStyle("")); + try expectEqual(@as(?Style, null), parseStyle("0")); + try expectEqual(@as(?Style, null), parseStyle("00")); +} + +test "parse bold style" { + const actual = parseStyle("01"); + const expected = Style{ + .font_style = FontStyle.bold, + }; + + try expectEqual(@as(?Style, expected), actual); +} + +test "parse yellow style" { + const actual = parseStyle("33"); + const expected = Style{ + .foreground = Color.Yellow, + + .font_style = FontStyle{}, + }; + + try expectEqual(@as(?Style, expected), actual); +} + +test "parse some fixed color" { + const actual = parseStyle("38;5;220;1"); + const expected = Style{ + .foreground = Color{ .Fixed = 220 }, + + .font_style = FontStyle.bold, + }; + + try expectEqual(@as(?Style, expected), actual); +} + +test "parse some rgb color" { + const actual = parseStyle("38;2;123;123;123;1"); + const expected = Style{ + .foreground = Color{ .RGB = .{ .r = 123, .g = 123, .b = 123 } }, + + .font_style = FontStyle.bold, + }; + + try expectEqual(@as(?Style, expected), actual); +} + +test "parse wrong rgb color" { + const actual = parseStyle("38;2;123"); + try expectEqual(@as(?Style, null), actual); +} diff --git a/src/root.zig b/src/root.zig new file mode 100644 index 0000000..ecfeade --- /dev/null +++ b/src/root.zig @@ -0,0 +1,10 @@ +const std = @import("std"); +const testing = std.testing; + +export fn add(a: i32, b: i32) i32 { + return a + b; +} + +test "basic add functionality" { + try testing.expect(add(3, 7) == 10); +} diff --git a/src/style.zig b/src/style.zig new file mode 100644 index 0000000..268c1a8 --- /dev/null +++ b/src/style.zig @@ -0,0 +1,222 @@ +const std = @import("std"); +pub const format = @import("format.zig"); +pub const parse = @import("parse_style.zig"); +const meta = std.meta; +const expect = std.testing.expect; +const expectEqual = std.testing.expectEqual; + +pub const ColorRGB = struct { + r: u8, + g: u8, + b: u8, + + const Self = @This(); + + pub fn eql(self: Self, other: Self) bool { + return meta.eql(self, other); + } +}; + +pub const Color = union(enum) { + Default, + Black, + Red, + Green, + Yellow, + Blue, + Magenta, + Cyan, + White, + Fixed: u8, + Grey: u8, + RGB: ColorRGB, + + const Self = @This(); + + pub fn eql(self: Self, other: Self) bool { + return meta.eql(self, other); + } +}; + +pub const FontStyle = packed struct { + bold: bool = false, + dim: bool = false, + italic: bool = false, + underline: bool = false, + slowblink: bool = false, + rapidblink: bool = false, + reverse: bool = false, + hidden: bool = false, + crossedout: bool = false, + fraktur: bool = false, + overline: bool = false, + + const Self = @This(); + + pub const bold = Self{ + .bold = true, + }; + + pub const dim = Self{ + .dim = true, + }; + + pub const italic = Self{ + .italic = true, + }; + + pub const underline = Self{ + .underline = true, + }; + + pub const slowblink = Self{ + .slowblink = true, + }; + + pub const rapidblink = Self{ + .rapidblink = true, + }; + + pub const reverse = Self{ + .reverse = true, + }; + + pub const hidden = Self{ + .hidden = true, + }; + + pub const crossedout = Self{ + .crossedout = true, + }; + + pub const fraktur = Self{ + .fraktur = true, + }; + + pub const overline = Self{ + .overline = true, + }; + + pub fn toU11(self: Self) u11 { + return @bitCast(self); + } + + pub fn fromU11(bits: u11) Self { + return @bitCast(bits); + } + + /// Returns true iff this font style contains no attributes + pub fn isDefault(self: Self) bool { + return self.toU11() == 0; + } + + /// Returns true iff these font styles contain exactly the same + /// attributes + pub fn eql(self: Self, other: Self) bool { + return self.toU11() == other.toU11(); + } + + /// Returns true iff self is a subset of the attributes of + /// other, i.e. all attributes of self are at least present in + /// other as well + pub fn subsetOf(self: Self, other: Self) bool { + return self.toU11() & other.toU11() == self.toU11(); + } + + /// Returns this font style with all attributes removed that are + /// contained in other + pub fn without(self: Self, other: Self) Self { + return fromU11(self.toU11() & ~other.toU11()); + } +}; + +test "FontStyle bits" { + try expectEqual(@as(u11, 0), (FontStyle{}).toU11()); + try expectEqual(@as(u11, 1), (FontStyle.bold).toU11()); + try expectEqual(@as(u11, 1 << 2), (FontStyle.italic).toU11()); + try expectEqual(@as(u11, 1 << 2) | 1, (FontStyle{ .bold = true, .italic = true }).toU11()); + try expectEqual(FontStyle{}, FontStyle.fromU11((FontStyle{}).toU11())); + try expectEqual(FontStyle.bold, FontStyle.fromU11((FontStyle.bold).toU11())); +} + +test "FontStyle subsetOf" { + const default = FontStyle{}; + const bold = FontStyle.bold; + const italic = FontStyle.italic; + const bold_and_italic = FontStyle{ .bold = true, .italic = true }; + + try expect(default.subsetOf(default)); + try expect(default.subsetOf(bold)); + try expect(bold.subsetOf(bold)); + try expect(!bold.subsetOf(default)); + try expect(!bold.subsetOf(italic)); + try expect(default.subsetOf(bold_and_italic)); + try expect(bold.subsetOf(bold_and_italic)); + try expect(italic.subsetOf(bold_and_italic)); + try expect(bold_and_italic.subsetOf(bold_and_italic)); + try expect(!bold_and_italic.subsetOf(bold)); + try expect(!bold_and_italic.subsetOf(italic)); + try expect(!bold_and_italic.subsetOf(default)); +} + +test "FontStyle without" { + const default = FontStyle{}; + const bold = FontStyle.bold; + const italic = FontStyle.italic; + const bold_and_italic = FontStyle{ .bold = true, .italic = true }; + + try expectEqual(default, default.without(default)); + try expectEqual(bold, bold.without(default)); + try expectEqual(default, bold.without(bold)); + try expectEqual(bold, bold.without(italic)); + try expectEqual(bold, bold_and_italic.without(italic)); + try expectEqual(italic, bold_and_italic.without(bold)); + try expectEqual(default, bold_and_italic.without(bold_and_italic)); +} + +pub const Style = struct { + foreground: Color = .Default, + background: Color = .Default, + font_style: FontStyle = FontStyle{}, + + const Self = @This(); + + /// Returns true iff this style equals the other style in + /// foreground color, background color and font style + pub fn eql(self: Self, other: Self) bool { + if (!self.font_style.eql(other.font_style)) + return false; + + if (!meta.eql(self.foreground, other.foreground)) + return false; + + return meta.eql(self.background, other.background); + } + + /// Returns true iff this style equals the default set of styles + pub fn isDefault(self: Self) bool { + return eql(self, Self{}); + } + + pub const parse = @import("parse_style.zig").parseStyle; +}; + +test "style equality" { + const a = Style{}; + const b = Style{ + .font_style = FontStyle.bold, + }; + const c = Style{ + .foreground = Color.Red, + }; + + try expect(a.isDefault()); + + try expect(a.eql(a)); + try expect(b.eql(b)); + try expect(c.eql(c)); + + try expect(!a.eql(b)); + try expect(!b.eql(a)); + try expect(!a.eql(c)); +} diff --git a/src/zpinner.zig b/src/zpinner.zig new file mode 100644 index 0000000..9d51cf1 --- /dev/null +++ b/src/zpinner.zig @@ -0,0 +1,120 @@ +pub const std = @import("std"); +pub const chars = @import("characters.zig"); +pub const Color = @import("ansi.zig").Color; +pub const Style = @import("ansi.zig").Style; +pub const cursor = @import("cursor.zig"); +pub const format = @import("format.zig"); + +/// DefaultOptions is setting the default values +const DefaultOptions = struct { + /// delay is the speed of the indicator + delay: u64 = std.time.ns_per_s * 0.1, + /// chars to loop through see characters.zig + chars: []const u8 = chars.Snake, + /// prefix is the text preppended to the indicator + prefix: []const u8 = "", + /// suffix is the text appended to the indicator + suffix: []const u8 = "", + /// style the indicator should have + style: Style = .{ + .foreground = Color.Default, + .background = Color.Default, + }, +}; + +pub const Options = DefaultOptions; + +pub fn New(writer: anytype, opts: Options) Zpinner { + return Zpinner.init(writer, opts); +} + +const Zpinner = struct { + writer: std.io.AnyWriter, + mutex: std.Thread.Mutex, + delay: u64, + active: bool, + hide_cursor: bool, + options: DefaultOptions, + + const Self = @This(); + + pub fn init(writer: anytype, opts: Options) Zpinner { + return .{ + .writer = writer, + .mutex = .{}, + .delay = 1, + .active = false, + .hide_cursor = true, + .options = opts, + }; + } + + pub fn start(self: *Self) !void { + self.mutex.lock(); + + if (self.active or !is_tty()) { + self.mutex.unlock(); + return; + } + + if (self.hide_cursor) { + _ = try self.writer.print("\x1B[?25l", .{}); + } + self.active = true; + self.mutex.unlock(); + + const thread = try std.Thread.spawn(.{}, Self.run, .{self}); + thread.detach(); + } + + pub fn set_suffix(self: *Self, suffix: []const u8) void { + self.options.suffix = suffix; + } + + pub fn set_prefix(self: *Self, prefix: []const u8) void { + self.options.prefix = prefix; + } + + pub fn active(self: *Self) bool { + return self.active; + } + + fn is_tty() bool { + return std.io.getStdOut().isTty(); + } + + fn run(self: *Self) !void { + try format.updateStyle(self.writer, self.options.style, null); + while (true) { + // const chars = "⠋ ⠙ ⠹ ⠸ ⠼ ⠴ ⠦ ⠧ ⠇ ⠏"; + var splits = std.mem.split(u8, self.options.chars, " "); + while (splits.next()) |character| { + self.mutex.lock(); + if (!self.active) { + self.mutex.unlock(); + return; + } + try self.writer.print("\r{s}{s}{s}", .{ self.options.prefix, character, self.options.suffix }); + self.mutex.unlock(); + std.time.sleep(self.options.delay); + } + } + } + pub fn stop(self: *Self) !void { + try format.resetStyle(self.writer); + self.mutex.lock(); + + defer self.mutex.unlock(); + + if (self.active) { + self.active = false; + + if (self.hide_cursor) { + try cursor.showCursor(self.writer); + // try try self.writer.print("\x1B[?25h", .{}); + } + + try self.writer.print("\r", .{}); + } + } +};