Skip to content

feat: render text along a path (line and circle) in canvas/graph #18

@neomantra

Description

@neomantra

Add primitives to the canvas/graph package that render a text string along a path — a straight line and a circle/arc — placing each successive rune at successive cells of the path. Today the package draws
lines and circles of a single repeated rune (GetLinePoints, GetCirclePoints, GetFullCirclePoints, DrawLineSequence, …) and canvas.Model writes only horizontal strings (SetString). There is no way to write a
string that follows a line or curves around a circle.

Proposed API — in canvas/graph, consistent with the existing Draw*/Get* naming, the *canvas.Model receiver convention, and the functional-options Option pattern used elsewhere:

// DrawTextLine writes text rune-by-rune along the line from a to b.
func DrawTextLine(m *canvas.Model, a, b canvas.Point, text string, style lipgloss.Style, opts ...TextOption)

// DrawTextCircle writes text rune-by-rune around a circle of radius r centered at c.
func DrawTextCircle(m *canvas.Model, c canvas.Point, r int, text string, style lipgloss.Style, opts ...TextOption)

// DrawTextOnPoints is the underlying primitive: it places text along an
// ordered slice of points; the two functions above are thin wrappers.
func DrawTextOnPoints(m *canvas.Model, pts []canvas.Point, text string, style lipgloss.Style, opts ...TextOption)

The wrapping / fit concern — make this an explicit, documented part of the interface, not an implementation detail.

The rune count of text will rarely equal the cell count of the path. The interface must let the caller choose the behavior, e.g. via a TextOption / mode enum:

  • Truncate — stop when either the text or the path runs out (suggested default).
  • Repeat — tile the text to fill the whole path.
  • Stretch — distribute the runes evenly across the path, leaving gaps.
  • Wrap — when text exceeds the path, continue onto a parallel offset path (a concentric ring for circles, an offset line for lines). This is the trickiest mode; at minimum, document clearly whether it is
    supported or explicitly out of scope.

Whatever the default, overflow and underflow behavior must be a named, documented option so callers aren't surprised. Also document the behavior for degenerate paths (zero or one point).

Other design points to settle:

  1. Circle point ordering. GetCirclePoints returns points grouped by octant (midpoint-circle algorithm), not in circumferential order — unusable as-is for text. DrawTextCircle needs angularly-ordered points;
    expose a start angle and direction (clockwise / counter-clockwise) so callers control where text begins and which way it reads. Consider adding a reusable GetCirclePointsOrdered (or angular generator).
  2. Wide runes. Emoji and CJK glyphs occupy two terminal cells. Decide and document how the path advances over a wide rune (skip the next path cell, or place and accept overlap). Reuse the go-runewidth
    dependency the canvas already pulls in.
  3. Style. Accept a single lipgloss.Style like the other Draw* functions; per-rune styling is out of scope.
  4. Bounds. Silently skip off-canvas cells, matching SetRune/SetString behavior.

Acceptance: unit tests covering each fit mode for both line and circle — text shorter than, equal to, and longer than the path — plus a short examples/ program.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions