Powerline: Add half-circle rendering

This adds in-terminal rendering for the powerline glyphs E0B4 and E0B6,
similar to how we are rendering the triangle shapes currently.

The circle glyphs use a much more complex rendering due to the nuances
of drawing them: we use a midpoint algorithm for drawing on a 4x
supersampled matrix, fill, and then downsample. We use the same
downsampling approach as is done in the arc box drawing code.

The midpoint variant we're using here is described by Dennis Yurichev:
https://yurichev.com/news/20220322_circle/, although there are similar
variants elsewhere (some cited at the bottom of his article).
This commit is contained in:
Chris Marchesi
2023-11-30 12:34:24 -08:00
parent 824d0c5cd5
commit d34b5571d8
2 changed files with 161 additions and 2 deletions

View File

@ -148,6 +148,8 @@ const Kind = enum {
// Powerline fonts
0xE0B0,
0xE0B4,
0xE0B6,
0xE0B2,
0xE0B8,
0xE0BA,

View File

@ -56,7 +56,7 @@ pub fn renderGlyph(
defer canvas.deinit(alloc);
// Perform the actual drawing
try self.draw(&canvas, cp);
try self.draw(alloc, &canvas, cp);
// Write the drawing to the atlas
const region = try canvas.writeAtlas(alloc, atlas);
@ -77,7 +77,7 @@ pub fn renderGlyph(
};
}
fn draw(self: Powerline, canvas: *font.sprite.Canvas, cp: u32) !void {
fn draw(self: Powerline, alloc: Allocator, canvas: *font.sprite.Canvas, cp: u32) !void {
switch (cp) {
// Hard dividers and triangles
0xE0B0,
@ -88,6 +88,11 @@ fn draw(self: Powerline, canvas: *font.sprite.Canvas, cp: u32) !void {
0xE0BE,
=> try self.draw_wedge_triangle(canvas, cp),
// Half-circles
0xE0B4,
0xE0B6,
=> try self.draw_half_circle(alloc, canvas, cp),
else => return error.InvalidCodepoint,
}
}
@ -168,6 +173,156 @@ fn draw_wedge_triangle(self: Powerline, canvas: *font.sprite.Canvas, cp: u32) !v
}, .on);
}
fn draw_half_circle(self: Powerline, alloc: Allocator, canvas: *font.sprite.Canvas, cp: u32) !void {
const supersample = 4;
// We make a canvas big enough for the whole circle, with the supersample
// applied.
const width = self.width * 2 * supersample;
const height = self.height * supersample;
// We set a minimum super-sampled canvas to assert on. The minimum cell
// size is 1x3px, and this looked safe in empirical testing.
std.debug.assert(width >= 8); // 1 * 2 * 4
std.debug.assert(height >= 12); // 3 * 4
const center_x = width / 2 - 1;
const center_y = height / 2 - 1;
// Our radius.
const radius = @min(width, height) / 2;
// Pre-allocate a matrix to plot the points on.
const cap = height * width;
var points = try alloc.alloc(u8, cap);
defer alloc.free(points);
@memset(points, 0);
{
// Using a midpoint algorithm.
// As explained on https://yurichev.com/news/20220322_circle/ and
// other sites
var x: i32 = @intCast(radius);
var y: i32 = 0;
var radius_err: i32 = 0;
const cx: i32 = @intCast(center_x);
const cy: i32 = @intCast(center_y);
while (x >= y) {
// Right side
const x1 = @max(0, cx + x);
const y1 = @max(0, cy + y);
const x2 = @max(0, cx + x);
const y2 = @max(0, cy - y);
const x3 = @max(0, cx + y);
const y3 = @max(0, cy + x);
const x4 = @max(0, cx + y);
const y4 = @max(0, cy - x);
// Left side
const x5 = @max(0, cx - x);
const y5 = @max(0, cy + y);
const x6 = @max(0, cx - x);
const y6 = @max(0, cy - y);
const x7 = @max(0, cx - y);
const y7 = @max(0, cy + x);
const x8 = @max(0, cx - y);
const y8 = @max(0, cy - x);
// Points
const p1 = y1 * width + x1;
const p2 = y2 * width + x2;
const p3 = y3 * width + x3;
const p4 = y4 * width + x4;
const p5 = y5 * width + x5;
const p6 = y6 * width + x6;
const p7 = y7 * width + x7;
const p8 = y8 * width + x8;
// Set the points in the matrix, ignore any out of bounds
if (p1 < cap) points[p1] = 0xFF;
if (p2 < cap) points[p2] = 0xFF;
if (p3 < cap) points[p3] = 0xFF;
if (p4 < cap) points[p4] = 0xFF;
if (p5 < cap) points[p5] = 0xFF;
if (p6 < cap) points[p6] = 0xFF;
if (p7 < cap) points[p7] = 0xFF;
if (p8 < cap) points[p8] = 0xFF;
// Calculate next pixels based on midpoint bounds
y += 1;
radius_err += 2 * y + 1;
if (radius_err > 0) {
x -= 1;
radius_err -= 2 * x + 1;
}
}
}
// Fill
{
const u_height: u32 = @intCast(height);
const u_width: u32 = @intCast(width);
for (0..u_height) |yf| {
for (0..u_width) |left| {
// Count forward from the left to the first filled pixel
if (points[yf * u_width + left] != 0) {
// Count back to our left point from the right to the first
// filled pixel on the other side.
var right: usize = u_width - 1;
while (right > left) : (right -= 1) {
if (points[yf * u_width + right] != 0) {
break;
}
}
// Start filling 1 index after the left and go until we hit
// the right; this will be a no-op if the line length is <
// 3 as both left and right will have already been filled.
const start = yf * u_width + left;
const end = yf * u_width + right;
if (end - start >= 3) {
for (start + 1..end) |idx| {
points[idx] = 0xFF;
}
}
}
}
}
}
// Now that we have our points, we need to "split" our matrix on the x
// axis for the downsample.
{
// The side of the circle we're drawing
const offset_j: u32 = if (cp == 0xE0B4) center_x + 1 else 0;
for (0..self.height) |r| {
for (0..self.width) |c| {
var total: u32 = 0;
for (0..supersample) |i| {
for (0..supersample) |j| {
const idx = (r * supersample + i) * width + (c * supersample + j + offset_j);
total += points[idx];
}
}
const average = @as(u8, @intCast(@min(total / (supersample * supersample), 0xFF)));
canvas.rect(
.{
.x = @intCast(c),
.y = @intCast(r),
.width = 1,
.height = 1,
},
@as(font.sprite.Color, @enumFromInt(average)),
);
}
}
}
}
test "all" {
const testing = std.testing;
const alloc = testing.allocator;
@ -179,6 +334,8 @@ test "all" {
0xE0BA,
0xE0BC,
0xE0BE,
0xE0B4,
0xE0B6,
};
for (cps) |cp| {
var atlas_greyscale = try font.Atlas.init(alloc, 512, .greyscale);