1
0
Fork 0

feat: Partially implement new text layout & rendering system: Spans

Chunks are NYI
This commit is contained in:
LordMZTE 2023-11-30 20:57:03 +01:00
parent 3446418b49
commit 9a2080673c
Signed by: LordMZTE
GPG key ID: B64802DC33A64FF6
16 changed files with 280 additions and 171 deletions

View file

@ -1,6 +1,7 @@
//! Style options for the button widget.
const BackgroundStyle = @import("../background_style.zig").BackgroundStyle;
const Color = @import("../Color.zig");
const Style = @import("../text/Style.zig");
background: BackgroundStyle,
@ -9,7 +10,4 @@ background_hovered: BackgroundStyle,
/// Spacing between the text and the button's outer bounds.
padding: usize,
/// Font size to use for the label.
font_size: usize,
text_color: Color,
font_style: Style,

View file

@ -1,5 +1,5 @@
//! This attreebute may be used in a widget that needs to render text to chose a font to pass to the
//! painter. A user should set this if text rendering is required.
const Font = @import("../font.zig").Font;
const Font = @import("../text/font.zig").Font;
font: *Font,

View file

@ -1,96 +0,0 @@
const std = @import("std");
const statspatch = @import("statspatch");
const zenolith = @import("main.zig");
const Size = @import("layout/Size.zig");
fn FontPrototype(comptime Self: type) type {
std.debug.assert(Self == Font);
return struct {
/// Performs layout on the given string of UTF-8 encoded text.
/// The wrap parameter controls how text should be wrapped. If set to none, no text wrapping
/// should be done, if set to word or glyph it should wrap whole words or whole glyphs
/// respectively. The size parameter is in px. Platforms where this isn't applicable
/// should try to make the font size match closely.
pub fn layout(
self: *Self,
text: []const u8,
size: usize,
wrap: TextWrap,
) !Chunk {
return try statspatch.implcall(
self,
.ptr,
"layout",
anyerror!Chunk,
.{ text, size, wrap },
);
}
/// Free resources associated with this font.
pub fn deinit(self: Self) void {
statspatch.implcallOptional(self, .self, "deinit", void, .{}) orelse {};
}
};
}
fn ChunkPrototype(comptime Self: type) type {
std.debug.assert(Self == Chunk);
return struct {
/// Returns the total size of the chunk.
/// This is often used for layout to determine the size of text.
pub fn getSize(self: Self) Size {
return statspatch.implcall(self, .self, "getSize", Size, .{});
}
/// Free resources associated with this chunk.
pub fn deinit(self: Self) void {
return statspatch.implcallOptional(self, .self, "deinit", void, .{}) orelse {};
}
};
}
/// Determines how text should be wrapped when doing text layout.
pub const TextWrap = union(enum) {
/// Don't wrap text.
none,
/// Wrap words after the given maximum width.
word: usize,
/// Wrap individual glyphs after the given maximum width.
glyph: usize,
};
pub const font_impls = impls: {
var implementations: []const type = &.{};
for (zenolith.platform_impls) |pi| {
if (@hasDecl(pi, "Font")) {
implementations = implementations ++ &[1]type{pi.Font};
}
}
break :impls implementations;
};
pub const chunk_impls = impls: {
var implementations: [font_impls.len]type = undefined;
for (&implementations, font_impls) |*chunk, font| {
chunk.* = font.Chunk;
}
break :impls &implementations;
};
/// The font type is a backend-specific statspatch type which encapsulates a font.
/// This is used with the painter API to draw text.
/// Platforms should specify their font implementation by declaring a Font declaration.
pub const Font = statspatch.StatspatchType(FontPrototype, void, font_impls);
/// A chunk represents a laid-out piece of text. This is created by the font using the layout
/// function. It typically contains information about the positions of individual glyphs, although
/// this is up to the backend.
/// Fonts should specify their chunk implementation by declaring a Chunk declaration.
pub const Chunk = statspatch.StatspatchType(ChunkPrototype, void, chunk_impls);

View file

@ -1,11 +1,13 @@
test {
_ = Constraints;
_ = Offset;
_ = Position;
_ = Rectangle;
_ = Size;
}
pub const Constraints = @import("layout/Constraints.zig");
pub const Offset = @import("layout/Offset.zig");
pub const Position = @import("layout/Position.zig");
pub const Rectangle = @import("layout/Rectangle.zig");
pub const Size = @import("layout/Size.zig");

4
src/layout/Offset.zig Normal file
View file

@ -0,0 +1,4 @@
//! An offset represents a signed, 2D Vector that can be added onto Positions.
x: isize,
y: isize,

View file

@ -1,5 +1,6 @@
//! A 2-Dimensional position, typically used to denote where a widget is relative to the top left.
const Size = @import("Size.zig");
const Offset = @import("Offset.zig");
x: usize,
y: usize,
@ -36,3 +37,12 @@ pub inline fn size(self: Position) Size {
.height = self.y,
};
}
/// Offsets this Position by the given Offset.
/// The caller asserts that this will not over or underflow.
pub inline fn offset(self: Position, by: Offset) Position {
return .{
.x = self.x +% @as(usize, @bitCast(by.x)),
.y = self.y +% @as(usize, @bitCast(by.y)),
};
}

View file

@ -5,10 +5,10 @@ const root = @import("root");
test {
_ = attreebute;
_ = backevent;
_ = font;
_ = layout;
_ = painter;
_ = platform;
_ = text;
_ = texture;
_ = treevent;
_ = util;
@ -19,10 +19,10 @@ test {
pub const attreebute = @import("attreebute.zig");
pub const backevent = @import("backevent.zig");
pub const font = @import("font.zig");
pub const layout = @import("layout.zig");
pub const painter = @import("painter.zig");
pub const platform = @import("platform.zig");
pub const text = @import("text.zig");
pub const texture = @import("texture.zig");
pub const treevent = @import("treevent.zig");
pub const util = @import("util.zig");

View file

@ -12,12 +12,12 @@ const std = @import("std");
const statspatch = @import("statspatch");
const zenolith = @import("main.zig");
const font = @import("font.zig");
const Color = @import("Color.zig");
const Position = @import("layout/Position.zig");
const Rectangle = @import("layout/Rectangle.zig");
const Size = @import("layout/Size.zig");
const Span = @import("text/Span.zig");
const Texture = @import("texture.zig").Texture;
fn Prototype(comptime Self: type) type {
@ -138,22 +138,10 @@ fn Prototype(comptime Self: type) type {
);
}
/// Draw a given Chunk of laid-out text, typically obtained through the font at the given
/// position. The caller asserts that the given chunk is compatible with this painter's
/// underlying platform.
pub fn text(
self: *Self,
pos: Position,
chunk: font.Chunk,
color: Color,
) !void {
return statspatch.implcall(
self,
.ptr,
"text",
anyerror!void,
.{ pos, chunk, color },
);
/// Draw the given span of text at the given position.
/// The caller asserts that the font of the span is from the same platform as this painter.
pub fn span(self: *Self, pos: Position, text_span: Span) !void {
return statspatch.implcall(self, .ptr, "span", anyerror!void, .{ pos, text_span });
}
};
}

11
src/text.zig Normal file
View file

@ -0,0 +1,11 @@
test {
_ = Font;
_ = Glyph;
_ = Span;
_ = Style;
}
pub const Font = @import("text/font.zig").Font;
pub const Glyph = @import("text/Glyph.zig");
pub const Span = @import("text/Span.zig");
pub const Style = @import("text/Style.zig");

23
src/text/Glyph.zig Normal file
View file

@ -0,0 +1,23 @@
//! A glyph represents the smallet possible unit of text with positioning information.
//! It is used for text layout and rendering.
const Size = @import("../layout/Size.zig");
const Offset = @import("../layout/Offset.zig");
/// The unicode codepoint this glyph corresponds to.
/// This is intentionally only one codepoint. While Unicode glyphs an consist of up to 7
/// codepoints (citation needed), these are not supported. Support might be implemented in the
/// future, but this is currently not planned.
codepoint: u21,
/// The size of this glyph. This is typically the boundig box of the glyph as drawn.
size: Size,
/// The offset of the character's top-left corner from the baseline of the text span.
/// When performing span layout, this is added onto the position of the cursor.
/// This being 0/0 will thus result in the glyph being aligned below the baseline.
/// This is typically has a negative Y offset to align the glyph above the baseline.
bearing: Offset,
/// How much the cursor should move to the right after this glyph.
advance: usize,

128
src/text/Span.zig Normal file
View file

@ -0,0 +1,128 @@
//! A span is a one-line piece of text. It consists of glyphs and performs single-line layout on them.
//! It also has information on the font, style, color as well as bounding boxes.
const std = @import("std");
const Font = @import("font.zig").Font;
const Glyph = @import("Glyph.zig");
const Position = @import("../layout/Position.zig");
const Size = @import("../layout/Size.zig");
const Style = @import("Style.zig");
glyphs: std.MultiArrayList(PositionedGlyph) = .{},
font: *Font,
style: Style,
/// The Y-coordinate of the baseline of this span relative to it's top.
/// The chunker uses this for aligning spans.
baseline_y: usize = 0,
const Span = @This();
pub const PositionedGlyph = struct {
glyph: Glyph,
position: Position,
};
pub const InitOptions = struct {
font: *Font,
style: Style = .{},
text: []const u8,
};
/// Initializes this span with some text. This performs layout.
/// The caller must call deinit when done to free memory.
pub fn init(alloc: std.mem.Allocator, opts: InitOptions) !Span {
var self = Span{
.font = opts.font,
.style = opts.style,
};
try self.updateGlyphs(alloc, .{ .text = opts.text });
self.layout();
return self;
}
pub const UpdateGlyphsOptions = struct {
font: ?*Font = null,
style: ?Style = null,
text: []const u8,
};
/// Update the glyphs in this span to those of the given string.
/// Note that this does not recalculate positions! The caller must assure that `layout` is called
/// after this.
pub fn updateGlyphs(
self: *Span,
alloc: std.mem.Allocator,
opts: UpdateGlyphsOptions,
) !void {
if (opts.style) |style| self.style = style;
if (opts.font) |font| self.font = font;
self.glyphs.shrinkRetainingCapacity(0);
var iter = std.unicode.Utf8Iterator{ .i = 0, .bytes = opts.text };
while (iter.nextCodepoint()) |codepoint| {
try self.glyphs.append(alloc, .{
.glyph = try self.font.getGlyph(codepoint, self.style),
.position = Position.zero,
});
}
}
/// Positions the glyphs of the span and sets baseline_y.
pub fn layout(self: *Span) void {
const glyphslice = self.glyphs.slice();
// We start at the middle of the "coordinate system" here to leave as much space as possible
// in all directions. This is later compensated for.
var cursor = Position{
.x = std.math.maxInt(usize) / 2,
.y = std.math.maxInt(usize) / 2,
};
// Minimum position coordinate of the glyphs.
var min_pos = Position{
.x = std.math.maxInt(usize),
.y = std.math.maxInt(usize),
};
for (glyphslice.items(.glyph), glyphslice.items(.position)) |glyph, *position| {
position.* = cursor.offset(glyph.bearing);
if (position.x < min_pos.x) min_pos.x = position.x;
if (position.y < min_pos.y) min_pos.y = position.y;
cursor.x += glyph.advance;
}
for (glyphslice.items(.position)) |*pos| {
pos.* = pos.sub(min_pos);
}
self.baseline_y = cursor.y - min_pos.y;
}
/// Free owned data. Caller must provide the same allocator as to init!
pub fn deinit(self_: Span, alloc: std.mem.Allocator) void {
var self = self_;
self.glyphs.deinit(alloc);
}
/// This determines the size of the span as rendered. This fully contains the glyphs.
pub fn renderSize(self: Span) Size {
var size = Size.zero;
const glyphslice = self.glyphs.slice();
for (glyphslice.items(.glyph), glyphslice.items(.position)) |glyph, pos| {
const xmax = glyph.size.width + pos.x;
const ymax = glyph.size.height + pos.y;
if (xmax > size.width) size.width = xmax;
if (ymax > size.height) size.height = ymax;
}
return size;
}

10
src/text/Style.zig Normal file
View file

@ -0,0 +1,10 @@
//! Style is text theming information applied to a font. It is contained within spans.
const Color = @import("../Color.zig");
/// This is the Size of the font in pixels. Some backends (namely, TUI-based ones) may not support this.
size: usize = 24,
bold: bool = false,
italic: bool = false,
underlined: bool = false,
color: Color = Color.white(0xff),

51
src/text/font.zig Normal file
View file

@ -0,0 +1,51 @@
const std = @import("std");
const statspatch = @import("statspatch");
const zenolith = @import("../main.zig");
const Size = @import("../layout/Size.zig");
const Style = @import("../text/Style.zig");
const Glyph = @import("Glyph.zig");
fn FontPrototype(comptime Self: type) type {
std.debug.assert(Self == Font);
return struct {
/// For a given font size in pixels, returns the offset between lines.
pub fn yOffset(self: *Self, size: usize) usize {
return statspatch.implcall(self, .ptr, "yOffset", usize, .{size});
}
pub fn getGlyph(self: *Self, codepoint: u21, style: Style) !Glyph {
return try statspatch.implcall(
self,
.ptr,
"getGlyph",
anyerror!Glyph,
.{ codepoint, style },
);
}
/// Free resources associated with this font.
pub fn deinit(self: *Self) void {
statspatch.implcallOptional(self, .ptr, "deinit", void, .{}) orelse {};
}
};
}
pub const font_impls = impls: {
var implementations: []const type = &.{};
for (zenolith.platform_impls) |pi| {
if (@hasDecl(pi, "Font")) {
implementations = implementations ++ &[1]type{pi.Font};
}
}
break :impls implementations;
};
/// The font type is a backend-specific statspatch type which encapsulates a font.
/// This is used with the painter API to draw text.
/// Platforms should specify their font implementation by declaring a Font declaration.
pub const Font = statspatch.StatspatchType(FontPrototype, void, font_impls);

View file

@ -175,7 +175,7 @@ test "widget" {
});
widget.data.attreebutes = AttreebuteMap.init();
_ = try widget.data.attreebutes.?.put(std.testing.allocator, u32, 42);
(try widget.data.attreebutes.?.mod(std.testing.allocator, u32)).* = 42;
try std.testing.expectEqual(@as(u32, 42), widget.getAttreebute(u32).?.*);
}

View file

@ -4,15 +4,15 @@ const std = @import("std");
const attreebute = @import("../attreebute.zig");
const backevent = @import("../backevent.zig");
const font = @import("../font.zig");
const treev = @import("../treevent.zig");
const layout = @import("../layout.zig");
const Color = @import("../Color.zig");
const Span = @import("../text/Span.zig");
const Widget = @import("../widget.zig").Widget;
label_str: []const u8,
chunk: ?font.Chunk,
span: ?Span,
hovered: bool,
const Button = @This();
@ -20,7 +20,7 @@ const Button = @This();
pub fn init(alloc: std.mem.Allocator, label: []const u8) !*Widget {
const self = Button{
.label_str = label,
.chunk = null,
.span = null,
.hovered = false,
};
@ -28,9 +28,7 @@ pub fn init(alloc: std.mem.Allocator, label: []const u8) !*Widget {
}
pub fn deinit(self: *Button, selfw: *Widget) void {
_ = selfw;
if (self.chunk) |chunk|
chunk.deinit();
if (self.span) |span| span.deinit(selfw.data.allocator);
}
pub fn treevent(self: *Button, selfw: *Widget, tv: anytype) !void {
@ -38,22 +36,30 @@ pub fn treevent(self: *Button, selfw: *Widget, tv: anytype) !void {
treev.LayoutSize => {
const style = selfw.getAttreebute(attreebute.ButtonStyle) orelse
@panic("The Button widget must have the ButtonStyle attreebute set!");
if (self.chunk == null) {
if (self.span == null) {
const curfont = (selfw.getAttreebute(attreebute.CurrentFont) orelse
@panic("The Button widget must have the CurrentFont attreebute set!")).font;
self.chunk = try curfont.layout(self.label_str, style.font_size, .none);
self.span = try Span.init(selfw.data.allocator, .{
.font = curfont,
.style = style.font_style,
.text = self.label_str,
});
}
selfw.data.size = self.chunk.?.getSize().add(layout.Size.two(style.padding * 2));
selfw.data.size = self.span.?.renderSize().add(layout.Size.two(style.padding * 2));
},
treev.Draw => {
const style = selfw.getAttreebute(attreebute.ButtonStyle) orelse
@panic("The Button widget must have the ButtonStyle attreebute set!");
if (self.chunk == null) {
if (self.span == null) {
const curfont = (selfw.getAttreebute(attreebute.CurrentFont) orelse
@panic("The Button widget must have the CurrentFont attreebute set!")).font;
self.chunk = try curfont.layout(self.label_str, style.font_size, .none);
self.span = try Span.init(selfw.data.allocator, .{
.font = curfont,
.style = style.font_style,
.text = self.label_str,
});
}
try (if (self.hovered) style.background_hovered else style.background).drawBackground(
@ -61,10 +67,9 @@ pub fn treevent(self: *Button, selfw: *Widget, tv: anytype) !void {
.{ .pos = selfw.data.position, .size = selfw.data.size },
);
try tv.painter.text(
try tv.painter.span(
selfw.data.position.add(layout.Position.two(style.padding)),
self.chunk.?,
style.text_color,
self.span.?,
);
},

View file

@ -1,66 +1,41 @@
//! A simple text label with a given color and size.
// TODO: use CurrentFont
// TODO: free self.chunk
const std = @import("std");
const font = @import("../font.zig");
const font = @import("../text/font.zig");
const treev = @import("../treevent.zig");
const layout = @import("../layout.zig");
const Color = @import("../Color.zig");
const Span = @import("../text/Span.zig");
const Widget = @import("../widget.zig").Widget;
font: *font.Font,
chunk: font.Chunk,
color: Color,
size: usize,
span: Span,
const Label = @This();
pub const LabelOptions = struct {
alloc: std.mem.Allocator,
font: *font.Font,
text: []const u8,
size: usize = 32,
color: Color = Color.white(0xff),
};
pub const UpdateOptions = struct {
text: []const u8,
font: ?*font.Font = null,
size: ?usize = null,
color: ?Color = null,
};
pub fn init(opts: LabelOptions) !*Widget {
pub fn init(alloc: std.mem.Allocator, opts: Span.InitOptions) !*Widget {
const self = Label{
.font = opts.font,
.chunk = try opts.font.layout(opts.text, opts.size, .none),
.color = opts.color,
.size = opts.size,
.span = try Span.init(alloc, opts),
};
errdefer self.chunk.deinit();
errdefer self.span.deinit(alloc);
return try Widget.init(opts.alloc, self);
return try Widget.init(alloc, self);
}
pub fn update(self: *Label, opts: UpdateOptions) !void {
if (opts.font) |f| self.font = f;
if (opts.size) |s| self.size = s;
if (opts.color) |col| self.color = col;
const oldchunk = self.chunk;
self.chunk = try self.font.layout(opts.text, self.size, .none);
defer oldchunk.deinit();
pub fn deinit(self: *Label, selfw: *Widget) void {
self.span.deinit(selfw.data.allocator);
}
pub fn treevent(self: *Label, selfw: *Widget, tv: anytype) !void {
switch (@TypeOf(tv)) {
treev.LayoutSize => {
selfw.data.size = tv.constraints.clamp(self.chunk.getSize());
selfw.data.size = tv.constraints.clamp(self.span.renderSize());
},
treev.Draw => {
try tv.painter.text(selfw.data.position, self.chunk, self.color);
try tv.painter.span(selfw.data.position, self.span);
},
else => try tv.dispatch(selfw),
}