///|
/// Border characters used to render a box around styled content.
pub(all) struct Border {
top_left : String
top_right : String
bottom_left : String
bottom_right : String
horizontal : String
vertical : String
} derive(Debug, Eq)
///|
/// Horizontal alignment within a styled width.
pub(all) enum Align {
Left
Center
Right
} derive(Debug, Eq)
///|
/// Rich color representation used by the styling APIs.
pub(all) enum Color {
Ansi(Int)
RGB(Int, Int, Int)
Hex(String)
Adaptive(Color, Color)
} derive(Debug, Eq)
///|
/// Vertical alignment for layout helpers.
pub(all) enum VerticalAlign {
Top
Middle
Bottom
} derive(Debug, Eq)
///|
/// A composable style description for terminal strings.
pub(all) struct Style {
fg : Int?
bg : Int?
fg_ext : Color?
bg_ext : Color?
border_fg : Color?
border_bg : Color?
bold : Bool
dim : Bool
italic : Bool
underline : Bool
blink : Bool
reverse : Bool
strikethrough : Bool
border : Border?
border_top : Bool
border_right : Bool
border_bottom : Bool
border_left : Bool
padding_top : Int
padding_bottom : Int
padding_left : Int
padding_right : Int
margin_top : Int
margin_bottom : Int
margin_left : Int
margin_right : Int
width : Int
height : Int?
min_width : Int
min_height : Int
max_width : Int?
max_height : Int?
align : Align
} derive(Debug, Eq)
///|
pub fn border_normal() -> Border {
{
top_left: "┌",
top_right: "┐",
bottom_left: "└",
bottom_right: "┘",
horizontal: "─",
vertical: "│",
}
}
///|
pub fn border_rounded() -> Border {
{
top_left: "╭",
top_right: "╮",
bottom_left: "╰",
bottom_right: "╯",
horizontal: "─",
vertical: "│",
}
}
///|
pub fn border_thick() -> Border {
{
top_left: "┏",
top_right: "┓",
bottom_left: "┗",
bottom_right: "┛",
horizontal: "━",
vertical: "┃",
}
}
///|
pub fn border_double() -> Border {
{
top_left: "╔",
top_right: "╗",
bottom_left: "╚",
bottom_right: "╝",
horizontal: "═",
vertical: "║",
}
}
///|
pub fn border_ascii() -> Border {
{
top_left: "+",
top_right: "+",
bottom_left: "+",
bottom_right: "+",
horizontal: "-",
vertical: "|",
}
}
///|
pub fn color_ansi(n : Int) -> Color {
Ansi(@internal.clamp_non_negative(n))
}
///|
pub fn color_rgb(r : Int, g : Int, b : Int) -> Color {
RGB(clamp_channel(r), clamp_channel(g), clamp_channel(b))
}
///|
pub fn color_hex(hex : String) -> Color {
Hex(hex)
}
///|
pub fn color_adaptive(light : Color, dark : Color) -> Color {
Adaptive(light, dark)
}
///|
pub fn style() -> Style {
{
fg: None,
bg: None,
fg_ext: None,
bg_ext: None,
border_fg: None,
border_bg: None,
bold: false,
dim: false,
italic: false,
underline: false,
blink: false,
reverse: false,
strikethrough: false,
border: None,
border_top: true,
border_right: true,
border_bottom: true,
border_left: true,
padding_top: 0,
padding_bottom: 0,
padding_left: 0,
padding_right: 0,
margin_top: 0,
margin_bottom: 0,
margin_left: 0,
margin_right: 0,
width: 0,
height: None,
min_width: 0,
min_height: 0,
max_width: None,
max_height: None,
align: Left,
}
}
///|
pub fn Style::fg_color(self : Style, n : Int) -> Style {
let color = color_ansi(n)
{ ..self, fg: Some(@internal.clamp_non_negative(n)), fg_ext: Some(color) }
}
///|
pub fn Style::bg_color(self : Style, n : Int) -> Style {
let color = color_ansi(n)
{ ..self, bg: Some(@internal.clamp_non_negative(n)), bg_ext: Some(color) }
}
///|
pub fn Style::foreground(self : Style, color : Color) -> Style {
{
..self,
fg_ext: Some(color),
fg: match color {
Ansi(n) => Some(@internal.clamp_non_negative(n))
Adaptive(_, _) => None
_ => None
},
}
}
///|
pub fn Style::background(self : Style, color : Color) -> Style {
{
..self,
bg_ext: Some(color),
bg: match color {
Ansi(n) => Some(@internal.clamp_non_negative(n))
Adaptive(_, _) => None
_ => None
},
}
}
///|
pub fn Style::foreground_rgb(self : Style, r : Int, g : Int, b : Int) -> Style {
self.foreground(color_rgb(r, g, b))
}
///|
pub fn Style::background_rgb(self : Style, r : Int, g : Int, b : Int) -> Style {
self.background(color_rgb(r, g, b))
}
///|
pub fn Style::foreground_hex(self : Style, hex : String) -> Style {
self.foreground(color_hex(hex))
}
///|
pub fn Style::background_hex(self : Style, hex : String) -> Style {
self.background(color_hex(hex))
}
///|
pub fn Style::border_foreground(self : Style, color : Color) -> Style {
{ ..self, border_fg: Some(color) }
}
///|
pub fn Style::border_background(self : Style, color : Color) -> Style {
{ ..self, border_bg: Some(color) }
}
///|
pub fn Style::bold(self : Style) -> Style {
{ ..self, bold: true }
}
///|
pub fn Style::dim(self : Style) -> Style {
{ ..self, dim: true }
}
///|
pub fn Style::faint(self : Style) -> Style {
self.dim()
}
///|
pub fn Style::italic(self : Style) -> Style {
{ ..self, italic: true }
}
///|
pub fn Style::underline(self : Style) -> Style {
{ ..self, underline: true }
}
///|
pub fn Style::blink(self : Style) -> Style {
{ ..self, blink: true }
}
///|
pub fn Style::reverse(self : Style) -> Style {
{ ..self, reverse: true }
}
///|
pub fn Style::strikethrough(self : Style) -> Style {
{ ..self, strikethrough: true }
}
///|
pub fn Style::border(self : Style, b : Border) -> Style {
{ ..self, border: Some(b) }
}
///|
pub fn Style::border_sides(
self : Style,
top : Bool,
right : Bool,
bottom : Bool,
left : Bool,
) -> Style {
{
..self,
border_top: top,
border_right: right,
border_bottom: bottom,
border_left: left,
}
}
///|
pub fn Style::padding(
self : Style,
top : Int,
right : Int,
bottom : Int,
left : Int,
) -> Style {
{
..self,
padding_top: @internal.clamp_non_negative(top),
padding_right: @internal.clamp_non_negative(right),
padding_bottom: @internal.clamp_non_negative(bottom),
padding_left: @internal.clamp_non_negative(left),
}
}
///|
pub fn Style::margin(
self : Style,
top : Int,
right : Int,
bottom : Int,
left : Int,
) -> Style {
{
..self,
margin_top: @internal.clamp_non_negative(top),
margin_right: @internal.clamp_non_negative(right),
margin_bottom: @internal.clamp_non_negative(bottom),
margin_left: @internal.clamp_non_negative(left),
}
}
///|
pub fn Style::width(self : Style, w : Int) -> Style {
{ ..self, width: @internal.clamp_non_negative(w) }
}
///|
pub fn Style::height(self : Style, h : Int) -> Style {
{ ..self, height: Some(@internal.clamp_non_negative(h)) }
}
///|
pub fn Style::min_width(self : Style, width : Int) -> Style {
{ ..self, min_width: @internal.clamp_non_negative(width) }
}
///|
pub fn Style::min_height(self : Style, height : Int) -> Style {
{ ..self, min_height: @internal.clamp_non_negative(height) }
}
///|
pub fn Style::max_width(self : Style, width : Int) -> Style {
{ ..self, max_width: Some(@internal.clamp_non_negative(width)) }
}
///|
pub fn Style::max_height(self : Style, height : Int) -> Style {
{ ..self, max_height: Some(@internal.clamp_non_negative(height)) }
}
///|
pub fn Style::align(self : Style, a : Align) -> Style {
{ ..self, align: a }
}
///|
pub fn Style::render(self : Style, content : String) -> String {
self.render_with(content, @profile.ambient_context())
}
///|
pub fn Style::render_with(
self : Style,
content : String,
ctx : @profile.RenderContext,
) -> String {
let effective_style = {
..self,
fg: self.fg.map(@internal.clamp_non_negative),
bg: self.bg.map(@internal.clamp_non_negative),
padding_top: @internal.clamp_non_negative(self.padding_top),
padding_bottom: @internal.clamp_non_negative(self.padding_bottom),
padding_left: @internal.clamp_non_negative(self.padding_left),
padding_right: @internal.clamp_non_negative(self.padding_right),
margin_top: @internal.clamp_non_negative(self.margin_top),
margin_bottom: @internal.clamp_non_negative(self.margin_bottom),
margin_left: @internal.clamp_non_negative(self.margin_left),
margin_right: @internal.clamp_non_negative(self.margin_right),
width: @internal.clamp_non_negative(self.width),
height: self.height.map(@internal.clamp_non_negative),
min_width: @internal.clamp_non_negative(self.min_width),
min_height: @internal.clamp_non_negative(self.min_height),
max_width: self.max_width.map(@internal.clamp_non_negative),
max_height: self.max_height.map(@internal.clamp_non_negative),
}
let content_lines = split_lines(content)
let (content_widths, natural_width) = measure_line_widths(content_lines)
let target_width = resolve_width_from_natural(effective_style, natural_width)
let base_lines = shape_content_lines_with_widths(
content_lines,
content_widths,
target_width,
effective_style.align,
)
let target_height = resolve_height(effective_style, base_lines.length())
let height_fitted = fit_height(base_lines, target_width, target_height)
let padded_width = target_width +
effective_style.padding_left +
effective_style.padding_right
let padded = apply_padding_with_inner_width(
height_fitted, effective_style, target_width,
)
let bordered = match effective_style.border {
Some(border) =>
render_border_block(border, padded, padded_width, effective_style, ctx)
None => @internal.join_lines(padded)
}
let margined = apply_margin(
bordered,
effective_style.margin_top,
effective_style.margin_right,
effective_style.margin_bottom,
effective_style.margin_left,
)
apply_style_prefix(effective_style, margined, ctx)
}
///|
pub fn join_horizontal(
blocks : Array[String],
valign? : VerticalAlign = Top,
) -> String {
if blocks.is_empty() {
return ""
}
let split_blocks : Array[Array[String]] = []
let widths : Array[Int] = []
let heights : Array[Int] = []
let mut max_height = 0
for block in blocks {
let lines = split_lines(block)
let width = @internal.max_visible_width(lines, visible_width)
split_blocks.push(lines)
widths.push(width)
heights.push(lines.length())
if lines.length() > max_height {
max_height = lines.length()
}
}
let rows : Array[String] = []
for row in 0..= top_pad + heights[i] {
buf.write_string(String::make(widths[i], ' '))
} else {
buf.write_string(fit_line_width(lines[row - top_pad], widths[i], Left))
}
}
rows.push(buf.to_string())
}
@internal.join_lines(rows)
}
///|
pub fn join_vertical(blocks : Array[String], align? : Align = Left) -> String {
if blocks.is_empty() {
return ""
}
let split_blocks : Array[Array[String]] = []
let mut max_width = 0
for block in blocks {
let lines = split_lines(block)
let width = @internal.max_visible_width(lines, visible_width)
if width > max_width {
max_width = width
}
split_blocks.push(lines)
}
let rows : Array[String] = []
for block_lines in split_blocks {
for line in block_lines {
rows.push(fit_line_width(line, max_width, align))
}
}
@internal.join_lines(rows)
}
///|
pub fn place(
width : Int,
height : Int,
content : String,
align? : Align = Left,
valign? : VerticalAlign = Top,
) -> String {
let safe_width = @internal.clamp_non_negative(width)
let safe_height = @internal.clamp_non_negative(height)
let lines = split_lines(content)
let trimmed : Array[String] = []
for i in 0.. Int {
if n < 0 {
0
} else if n > 255 {
255
} else {
n
}
}
///|
fn effective_foreground(style : Style) -> Color? {
match style.fg_ext {
Some(color) => Some(color)
None => style.fg.map(color_ansi)
}
}
///|
fn effective_background(style : Style) -> Color? {
match style.bg_ext {
Some(color) => Some(color)
None => style.bg.map(color_ansi)
}
}
///|
fn resolve_width_from_natural(style : Style, natural : Int) -> Int {
let from_width = if style.width > 0 { style.width } else { natural }
let with_min = from_width.max(style.min_width)
match style.max_width {
Some(max_width) => with_min.min(max_width)
None => with_min
}
}
///|
fn measure_line_widths(lines : Array[String]) -> (Array[Int], Int) {
let widths : Array[Int] = []
let mut max_width = 0
for line in lines {
let width = style_visible_width(line)
widths.push(width)
if width > max_width {
max_width = width
}
}
(widths, max_width)
}
///|
fn style_visible_width(line : String) -> Int {
let mut width = 0
for ch in line {
let code = ch.to_int()
if code < 0x20 || code >= 0x7F {
return visible_width(line)
}
width = width + 1
}
width
}
///|
fn resolve_height(style : Style, line_count : Int) -> Int {
let from_height = match style.height {
Some(height) => height
None => line_count
}
let with_min = from_height.max(style.min_height)
match style.max_height {
Some(max_height) => with_min.min(max_height)
None => with_min
}
}
///|
fn shape_content_lines_with_widths(
lines : Array[String],
widths : Array[Int],
target_width : Int,
align : Align,
) -> Array[String] {
let shaped : Array[String] = []
for i, line in lines {
shaped.push(fit_line_width_known(line, widths[i], target_width, align))
}
shaped
}
///|
fn fit_line_width(line : String, target_width : Int, align : Align) -> String {
let width = style_visible_width(line)
fit_line_width_known(line, width, target_width, align)
}
///|
fn fit_line_width_known(
line : String,
width : Int,
target_width : Int,
align : Align,
) -> String {
if target_width <= 0 || width >= target_width {
let truncated = truncate(line, target_width)
let truncated_width = style_visible_width(truncated)
return pad_line_known_width(truncated, truncated_width, target_width, align)
}
pad_line_known_width(line, width, target_width, align)
}
///|
fn pad_line_known_width(
line : String,
width : Int,
target_width : Int,
align : Align,
) -> String {
if width >= target_width {
return line
}
let total_pad = target_width - width
match align {
Left => pad_right_known_width(line, width, target_width)
Center => {
let left_pad = total_pad / 2
let right_pad = total_pad - left_pad
write_padded_line(
String::make(left_pad, ' '),
line,
String::make(right_pad, ' '),
)
}
Right => write_padded_line(String::make(total_pad, ' '), line, "")
}
}
///|
fn pad_right_known_width(
line : String,
width : Int,
target_width : Int,
) -> String {
if width >= target_width {
return line
}
if line.contains("\u001b") {
return pad_right(line, target_width)
}
line + String::make(target_width - width, ' ')
}
///|
fn write_padded_line(left : String, line : String, right : String) -> String {
if left == "" && right == "" {
return line
}
let buf = StringBuilder::new(
size_hint=left.length() + line.length() + right.length(),
)
buf.write_string(left)
buf.write_string(line)
buf.write_string(right)
buf.to_string()
}
///|
fn fit_height(
lines : Array[String],
target_width : Int,
target_height : Int,
) -> Array[String] {
let fitted : Array[String] = []
for i in 0.. Array[String] {
let padded : Array[String] = []
let row_width = inner_width + self.padding_left + self.padding_right
let blank = String::make(row_width, ' ')
let left = String::make(self.padding_left, ' ')
let right = String::make(self.padding_right, ' ')
for _ in 0.. String {
if top == 0 && right == 0 && bottom == 0 && left == 0 {
return block
}
let lines = split_lines(block)
let (widths, inner_width) = measure_line_widths(lines)
let full_width = inner_width + left + right
let blank = String::make(full_width, ' ')
let left_pad = String::make(left, ' ')
let right_pad = String::make(right, ' ')
let rows : Array[String] = []
for _ in 0.. String {
if style.border_fg is None && style.border_bg is None {
return @internal.render_border(
border.top_left,
border.top_right,
border.bottom_left,
border.bottom_right,
border.horizontal,
border.vertical,
lines,
inner_width,
style_visible_width,
style.border_top,
style.border_right,
style.border_bottom,
style.border_left,
)
}
let top_left = @internal.normalize_border_piece(
border.top_left,
style_visible_width,
)
let top_right = @internal.normalize_border_piece(
border.top_right,
style_visible_width,
)
let bottom_left = @internal.normalize_border_piece(
border.bottom_left,
style_visible_width,
)
let bottom_right = @internal.normalize_border_piece(
border.bottom_right,
style_visible_width,
)
let horizontal_piece = @internal.normalize_border_piece(
border.horizontal,
style_visible_width,
)
let vertical_piece = @internal.normalize_border_piece(
border.vertical,
style_visible_width,
)
let horizontal = @internal.repeat_string(horizontal_piece, inner_width)
let rows : Array[String] = []
let border_prefix = build_border_ansi_prefix(style, ctx)
let content_prefix = build_ansi_prefix(style, ctx)
if style.border_top {
rows.push(
decorate_segment(
border_prefix,
(if style.border_left { top_left } else { "" }) +
horizontal +
(if style.border_right { top_right } else { "" }),
),
)
}
for line in lines {
rows.push(
decorate_segment(
border_prefix,
if style.border_left {
vertical_piece
} else {
""
},
) +
decorate_segment(content_prefix, line) +
decorate_segment(
border_prefix,
if style.border_right {
vertical_piece
} else {
""
},
),
)
}
if style.border_bottom {
rows.push(
decorate_segment(
border_prefix,
(if style.border_left { bottom_left } else { "" }) +
horizontal +
(if style.border_right { bottom_right } else { "" }),
),
)
}
@internal.join_lines(rows)
}
///|
fn decorate_segment(prefix : String, segment : String) -> String {
if prefix == "" || segment == "" {
segment
} else {
prefix + segment + reset()
}
}
///|
fn build_border_ansi_prefix(
style : Style,
ctx : @profile.RenderContext,
) -> String {
build_ansi_prefix_with_colors(
style,
ctx,
match style.border_fg {
Some(color) => Some(color)
None => effective_foreground(style)
},
match style.border_bg {
Some(color) => Some(color)
None => effective_background(style)
},
)
}
///|
fn apply_style_prefix(
style : Style,
block : String,
ctx : @profile.RenderContext,
) -> String {
if style.border is Some(_) &&
(style.border_fg is Some(_) || style.border_bg is Some(_)) {
return block
}
let prefix = build_ansi_prefix(style, ctx)
if prefix == "" {
block
} else {
prefix + block + reset()
}
}
///|
fn build_ansi_prefix(style : Style, ctx : @profile.RenderContext) -> String {
build_ansi_prefix_with_colors(
style,
ctx,
effective_foreground(style),
effective_background(style),
)
}
///|
fn build_ansi_prefix_with_colors(
style : Style,
ctx : @profile.RenderContext,
fg : Color?,
bg : Color?,
) -> String {
let buf = StringBuilder::new()
fg
.iter()
.each(fn(color) { buf.write_string(foreground_sequence(ctx, color)) })
bg
.iter()
.each(fn(color) { buf.write_string(background_sequence(ctx, color)) })
if style.bold {
buf.write_string(bold())
}
if style.dim {
buf.write_string(dim())
}
if style.italic {
buf.write_string("\{CSI}3m")
}
if style.underline {
buf.write_string(underline())
}
if style.blink {
buf.write_string(blink())
}
if style.reverse {
buf.write_string(reverse())
}
if style.strikethrough {
buf.write_string(strikethrough())
}
buf.to_string()
}
///|
fn color_value(
ctx : @profile.RenderContext,
color : Color,
) -> @profile.ColorValue? {
match color {
Ansi(n) => Some(@profile.Palette(@internal.clamp_non_negative(n)))
RGB(r, g, b) => Some(@profile.Rgb(r, g, b))
Hex(hex) =>
match hex_to_rgb(hex) {
Some((r, g, b)) => Some(@profile.Rgb(r, g, b))
None => None
}
Adaptive(light, dark) =>
match ctx.background {
Light => color_value(ctx, light)
Dark | Unknown => color_value(ctx, dark)
}
}
}
///|
fn foreground_sequence(ctx : @profile.RenderContext, color : Color) -> String {
match color_value(ctx, color) {
Some(value) => @profile.render_fg(ctx, value)
None => ""
}
}
///|
fn background_sequence(ctx : @profile.RenderContext, color : Color) -> String {
match color_value(ctx, color) {
Some(value) => @profile.render_bg(ctx, value)
None => ""
}
}
///|
fn hex_to_rgb(hex : String) -> (Int, Int, Int)? {
let view = if hex.length() > 0 && hex[0] == '#' { hex[:][1:] } else { hex[:] }
let normalized = if view.length() == 3 {
let buf = StringBuilder::new(size_hint=6)
for ch in view.iter() {
buf.write_char(ch)
buf.write_char(ch)
}
Some(buf.to_string())
} else if view.length() == 6 {
Some(view.to_owned())
} else {
None
}
match normalized {
Some(value) => {
let parts = value[:]
let r = try? @string.parse_uint(parts[0:2], base=16)
let g = try? @string.parse_uint(parts[2:4], base=16)
let b = try? @string.parse_uint(parts[4:6], base=16)
match (r, g, b) {
(Ok(rv), Ok(gv), Ok(bv)) =>
Some(
(
rv.reinterpret_as_int(),
gv.reinterpret_as_int(),
bv.reinterpret_as_int(),
),
)
_ => None
}
}
None => None
}
}
///|
fn vertical_offset(
container : Int,
content : Int,
align : VerticalAlign,
) -> Int {
if content >= container {
return 0
}
match align {
Top => 0
Middle => (container - content) / 2
Bottom => container - content
}
}