package screen import ( "fmt" "image/color" "strings" uv "github.com/charmbracelet/ultraviolet" "github.com/clipperhouse/uax29/v2/graphemes" ) // Context represents a drawing context for rendering operations on a screen. type Context struct { scr uv.Screen style uv.Style link uv.Link pos uv.Position } // NewContext creates a new drawing context for the given screen. func NewContext(scr uv.Screen) *Context { c := &Context{scr: scr} c.Reset() return c } // Reset resets the context to its default state. func (c *Context) Reset() { c.style = uv.Style{} c.link = uv.Link{} c.pos = uv.Position{X: 0, Y: 0} } // SetStyle sets the style of the context. func (c *Context) SetStyle(style uv.Style) { c.style = style } // WithStyle returns a copy of the context with the given style. func (c Context) WithStyle(style uv.Style) Context { c.SetStyle(style) return c } // SetLink sets the link of the context. func (c *Context) SetLink(link uv.Link) { c.link = link } // WithLink returns a copy of the context with the given link. func (c Context) WithLink(link uv.Link) Context { c.SetLink(link) return c } // SetAttrs sets the attributes of the context. func (c *Context) SetAttrs(attrs uint8) { c.style.Attrs = attrs } // WithAttrs returns a copy of the context with the given attributes. func (c Context) WithAttrs(attrs uint8) Context { c.SetAttrs(attrs) return c } // SetBackground sets the background color of the context. Use nil to reset to default. func (c *Context) SetBackground(bg color.Color) { c.style.Bg = bg } // WithBackground returns a copy of the context with the given background color. func (c Context) WithBackground(bg color.Color) Context { c.SetBackground(bg) return c } // SetForeground sets the foreground color of the context. Use nil to reset to default. func (c *Context) SetForeground(fg color.Color) { c.style.Fg = fg } // WithForeground returns a copy of the context with the given foreground color. func (c Context) WithForeground(fg color.Color) Context { c.SetForeground(fg) return c } // SetBold sets whether the text in the context should be bold. func (c *Context) SetBold(bold bool) { if bold { c.style.Attrs |= uv.AttrBold } else { c.style.Attrs &^= uv.AttrBold } } // WithBold returns a copy of the context with the given bold attribute. func (c Context) WithBold(bold bool) Context { c.SetBold(bold) return c } // SetItalic sets whether the text in the context should be italic. func (c *Context) SetItalic(italic bool) { if italic { c.style.Attrs |= uv.AttrItalic } else { c.style.Attrs &^= uv.AttrItalic } } // WithItalic returns a copy of the context with the given italic attribute. func (c Context) WithItalic(italic bool) Context { c.SetItalic(italic) return c } // SetStrikethrough sets whether the text in the context should be strikethrough. func (c *Context) SetStrikethrough(strikethrough bool) { if strikethrough { c.style.Attrs |= uv.AttrStrikethrough } else { c.style.Attrs &^= uv.AttrStrikethrough } } // WithStrikethrough returns a copy of the context with the given strikethrough // attribute. func (c Context) WithStrikethrough(strikethrough bool) Context { c.SetStrikethrough(strikethrough) return c } // SetFaint sets whether the text in the context should be faint. func (c *Context) SetFaint(faint bool) { if faint { c.style.Attrs |= uv.AttrFaint } else { c.style.Attrs &^= uv.AttrFaint } } // WithFaint returns a copy of the context with the given faint attribute. func (c Context) WithFaint(faint bool) Context { c.SetFaint(faint) return c } // SetBlink sets whether the text in the context should blink. func (c *Context) SetBlink(blink bool) { if blink { c.style.Attrs |= uv.AttrBlink } else { c.style.Attrs &^= uv.AttrBlink } } // WithBlink returns a copy of the context with the given blink attribute. func (c Context) WithBlink(blink bool) Context { c.SetBlink(blink) return c } // SetReverse sets whether the text in the context should be reversed. func (c *Context) SetReverse(reverse bool) { if reverse { c.style.Attrs |= uv.AttrReverse } else { c.style.Attrs &^= uv.AttrReverse } } // WithReverse returns a copy of the context with the given reverse attribute. func (c Context) WithReverse(reverse bool) Context { c.SetReverse(reverse) return c } // SetConceal sets whether the text in the context should be concealed. func (c *Context) SetConceal(conceal bool) { if conceal { c.style.Attrs |= uv.AttrConceal } else { c.style.Attrs &^= uv.AttrConceal } } // WithConceal returns a copy of the context with the given conceal attribute. func (c Context) WithConceal(conceal bool) Context { c.SetConceal(conceal) return c } // SetUnderlineStyle sets the underline style of the context. func (c *Context) SetUnderlineStyle(u uv.Underline) { c.style.Underline = u } // WithUnderlineStyle returns a copy of the context with the given underline style. func (c Context) WithUnderlineStyle(u uv.Underline) Context { c.SetUnderlineStyle(u) return c } // SetUnderline sets whether the text in the context should be underlined. // // This is a convenience method that sets the underline style to single or // none. It is equivalent to calling [Context.SetUnderlineStyle] with // [uv.UnderlineSingle] or [uv.UnderlineNone]. func (c *Context) SetUnderline(underline bool) { if underline { c.SetUnderlineStyle(uv.UnderlineSingle) } else { c.SetUnderlineStyle(uv.UnderlineNone) } } // WithUnderline returns a copy of the context with the given underline // attribute. func (c Context) WithUnderline(underline bool) Context { c.SetUnderline(underline) return c } // SetUnderlineColor sets the underline color of the context. Use nil to reset to default. func (c *Context) SetUnderlineColor(color color.Color) { c.style.UnderlineColor = color } // WithUnderlineColor returns a copy of the context with the given underline color. func (c Context) WithUnderlineColor(color color.Color) Context { c.SetUnderlineColor(color) return c } // SetURL sets the URL link for the context. Use an empty string to reset. func (c *Context) SetURL(url string, params ...string) { if url == "" { c.link = uv.Link{} return } c.link = uv.Link{ URL: url, Params: strings.Join(params, ":"), } } // WithURL returns a copy of the context with the given URL link. func (c Context) WithURL(url string, params ...string) Context { c.SetURL(url, params...) return c } // Position returns the current position of the context. func (c *Context) Position() (x, y int) { return c.pos.X, c.pos.Y } // SetPosition moves the current position of the context cursor to the given // coordinates. // // This is an alias for [Context.MoveTo]. func (c *Context) SetPosition(x, y int) { c.MoveTo(x, y) } // WithPosition returns a copy of the context with the given position. func (c Context) WithPosition(x, y int) Context { c.MoveTo(x, y) return c } // MoveTo moves the current position of the context cursor to the given // coordinates. func (c *Context) MoveTo(x, y int) { c.pos.X = x c.pos.Y = y } // Print prints the given string to the screen at the current position, updating // the position accordingly. func (c *Context) Print(v ...any) (int, error) { return fmt.Fprint(c, v...) } // Println prints the given string to the screen at the current position, appending // a newline, and updating the position accordingly. func (c *Context) Println(v ...any) (int, error) { return fmt.Fprintln(c, v...) } // Printf formats according to a format specifier and writes to the screen at the // current position, updating the position accordingly. func (c *Context) Printf(format string, a ...any) (int, error) { return fmt.Fprintf(c, format, a...) } // DrawString draws the given string at the given position with the current // style and link, cropping the string when it reaches the edge of the screen. func (c *Context) DrawString(s string, x, y int) { drawStringAt(c.scr, graphemes.FromString(s), x, y, c.style, c.link, false) } // DrawStringWrapped draws the given string at the given position with the current // style and link, wrapping the string when it reaches the edge of the screen. func (c *Context) DrawStringWrapped(s string, x, y int) { drawStringAt(c.scr, graphemes.FromString(s), x, y, c.style, c.link, true) } // Write implements the [io.Writer] interface for the context, writing the // given byte slice to the screen at the current position, updating the // position accordingly. func (c *Context) Write(p []byte) (n int, err error) { c.pos.X, c.pos.Y = drawStringAt(c.scr, graphemes.FromBytes(p), c.pos.X, c.pos.Y, c.style, c.link, true) return len(p), nil } // WriteString implements the [io.StringWriter] interface for the context, // writing the given string to the screen at the current position, updating the // position accordingly. func (c *Context) WriteString(s string) (n int, err error) { c.pos.X, c.pos.Y = drawStringAt(c.scr, graphemes.FromString(s), c.pos.X, c.pos.Y, c.style, c.link, true) return len(s), nil } func drawStringAt[T []byte | string](scr uv.Screen, grs *graphemes.Iterator[T], x, y int, style uv.Style, link uv.Link, wrap bool) (int, int) { bounds := scr.Bounds() bounds.Max.X -= bounds.Min.X bounds.Max.Y -= bounds.Min.Y bounds.Min.X = 0 bounds.Min.Y = 0 pos := uv.Pos(x, y) if !pos.In(bounds) { return x, y } wm := scr.WidthMethod() for grs.Next() { gr := string(grs.Value()) switch gr { case "\n": x = bounds.Min.X y++ continue } w := wm.StringWidth(gr) pos := uv.Pos(x, y) if x+w > bounds.Max.X { if wrap { x = bounds.Min.X y++ pos = uv.Pos(x, y) } else { break } } if !pos.In(bounds) { break } scr.SetCell(x, y, &uv.Cell{ Content: gr, Width: w, Style: style, Link: link, }) x += w if wrap && x >= bounds.Max.X { x = bounds.Min.X y++ } } return x, y }