Add styled and styled-segment builtins (#674)

* Add compatibility test with old implementation

* Add color type

* Add basic style structs and utilities

* Add structs for styled segments and texts

* Add default style transformers to reimplement $edit:styled~

* Add builtins to manipulate styled segments and texts

* Rename style 'underline' -> 'underlined'

* Fix test case

* Add conversion from styled text to ansi sequences

* Return errors rather than throwing

* Validate the type of boolean options

* Delegate old to new styled function

* Rebase for new test framework api and expand test cases

* Remove old builtin function $edit:styled~

* Use strings to represent colors

* Convert bool pointers to simple bool values

* Validate color strings

* Do no longer expose builtin style transformers

* Fix confusion about pointers

* Make outputs more stable

* Expand tests

* Use pointers instead of passing setter functions

* Unexport and rename color check

* Use the empty string for default colors

* Expand tests

* Simplify styled transformers

Now there are three transformers for each boolean style attribute that
allow setting, unsetting and toggling the corresponding attribute.

* Rework and add doc comments
This commit is contained in:
fehnomenal 2018-05-28 22:24:09 +02:00 committed by Qi Xiao
parent d120747bbf
commit e89fe48870
10 changed files with 668 additions and 40 deletions

View File

@ -93,7 +93,6 @@ func makeNs(ed *editor) eval.Ns {
"binding-table": eddefs.MakeBindingMap, "binding-table": eddefs.MakeBindingMap,
"insert-at-dot": ed.InsertAtDot, "insert-at-dot": ed.InsertAtDot,
"replace-input": ed.replaceInput, "replace-input": ed.replaceInput,
"styled": styled,
"key": ui.ToKey, "key": ui.ToKey,
"wordify": wordifyBuiltin, "wordify": wordifyBuiltin,
"-dump-buf": ed.dumpBuf, "-dump-buf": ed.dumpBuf,

View File

@ -1,30 +0,0 @@
package edcore
import (
"errors"
"github.com/elves/elvish/edit/ui"
"github.com/xiaq/persistent/vector"
)
var errStyledStyles = errors.New("styles must either be a string or list of strings")
// A constructor for *ui.Styled, for use in Elvish script.
func styled(text string, styles interface{}) (*ui.Styled, error) {
switch styles := styles.(type) {
case string:
return &ui.Styled{text, ui.StylesFromString(styles)}, nil
case vector.Vector:
converted := make([]string, 0, styles.Len())
for it := styles.Iterator(); it.HasElem(); it.Next() {
elem, ok := it.Elem().(string)
if !ok {
return nil, errStyledStyles
}
converted = append(converted, elem)
}
return &ui.Styled{text, ui.Styles(converted)}, nil
default:
return nil, errStyledStyles
}
}

100
eval/builtin_fn_styled.go Normal file
View File

@ -0,0 +1,100 @@
package eval
import (
"errors"
"fmt"
"github.com/elves/elvish/eval/vals"
"github.com/elves/elvish/styled"
)
var errStyledSegmentArgType = errors.New("argument to styled-segment must be a string or a styled segment")
func init() {
addBuiltinFns(map[string]interface{}{
"styled-segment": styledSegment,
"styled": styledBuiltin,
})
}
// Turns a string or styled Segment into a new styled Segment with the attributes
// from the supplied options applied to it. If the input is already a Segment its
// attributes are copied and modified.
func styledSegment(options RawOptions, input interface{}) (*styled.Segment, error) {
var text string
var style styled.Style
switch input := input.(type) {
case string:
text = input
case *styled.Segment:
text = input.Text
style = input.Style
default:
return nil, errStyledSegmentArgType
}
if err := style.ImportFromOptions(options); err != nil {
return nil, err
}
return &styled.Segment{
Text: text,
Style: style,
}, nil
}
// Turns a string, a styled Segment or a styled Text into a styled Text. This is done by
// applying a range of transformers to the input.
func styledBuiltin(fm *Frame, input interface{}, transformers ...interface{}) (*styled.Text, error) {
var text styled.Text
switch input := input.(type) {
case string:
text = styled.Text{styled.Segment{
Text: input,
Style: styled.Style{},
}}
case *styled.Segment:
text = styled.Text{*input}
case *styled.Text:
text = *input
default:
return nil, fmt.Errorf("expected string, styled segment or styled text; got %s", vals.Kind(input))
}
for _, transformer := range transformers {
switch transformer := transformer.(type) {
case string:
transformerFn := styled.FindTransformer(transformer)
if transformerFn == nil {
return nil, fmt.Errorf("'%s' is no valid style transformer", transformer)
}
for i, segment := range text {
text[i] = transformerFn(segment)
}
case Callable:
for i, segment := range text {
vs, err := fm.CaptureOutput(transformer, []interface{}{&segment}, NoOpts)
if err != nil {
return nil, err
}
if n := len(vs); n != 1 {
return nil, fmt.Errorf("style transformers must return a single styled segment; got %d", n)
} else if transformedSegment, ok := vs[0].(*styled.Segment); !ok {
return nil, fmt.Errorf("style transformers must return a styled segment; got %s", vals.Kind(vs[0]))
} else {
text[i] = *transformedSegment
}
}
default:
return nil, fmt.Errorf("need string or callable; got %s", vals.Kind(transformer))
}
}
return &text, nil
}

View File

@ -0,0 +1,100 @@
package eval
import (
"errors"
"testing"
)
func TestStyledString(t *testing.T) {
Test(t,
That("print (styled abc hopefully-never-exists)").ErrorsWith(errors.New("'hopefully-never-exists' is no valid style transformer")),
That("print (styled abc bold)").Prints("\033[1mabc\033[m"),
That("print (styled abc red cyan)").Prints("\033[36mabc\033[m"),
That("print (styled abc bg-green)").Prints("\033[42mabc\033[m"),
That("print (styled abc no-dim)").Prints("abc"),
)
}
func TestStyledSegment(t *testing.T) {
Test(t,
That("print (styled (styled-segment abc &fg-color=cyan) bold)").Prints("\033[1;36mabc\033[m"),
That("print (styled (styled-segment (styled-segment abc &fg-color=magenta) &dim=$true) cyan)").Prints("\033[2;36mabc\033[m"),
That("print (styled (styled-segment abc &inverse=$true) inverse)").Prints("\033[7mabc\033[m"),
That("print (styled (styled-segment abc) toggle-inverse)").Prints("\033[7mabc\033[m"),
That("print (styled (styled-segment abc &inverse=$true) no-inverse)").Prints("abc"),
That("print (styled (styled-segment abc &inverse=$true) toggle-inverse)").Prints("abc"),
)
}
func TestStyledText(t *testing.T) {
Test(t,
That("print (styled (styled abc red) blue)").Prints("\033[34mabc\033[m"),
That("print (styled (styled abc italic) red)").Prints("\033[3;31mabc\033[m"),
That("print (styled (styled abc inverse) inverse)").Prints("\033[7mabc\033[m"),
That("print (styled (styled abc inverse) no-inverse)").Prints("abc"),
That("print (styled (styled abc inverse) toggle-inverse)").Prints("abc"),
That("print (styled (styled abc inverse) toggle-inverse toggle-inverse)").Prints("\033[7mabc\033[m"),
)
}
func TestStyledConcat(t *testing.T) {
Test(t,
// string+segment
That("print abc(styled-segment abc &fg-color=red)").Prints("abc\033[31mabc\033[m"),
// segment+string
That("print (styled-segment abc &fg-color=red)abc").Prints("\033[31mabc\033[mabc"),
// segment+segment
That("print (styled-segment abc &bg-color=red)(styled-segment abc &fg-color=red)").Prints("\033[41mabc\033[m\033[31mabc\033[m"),
// segment+text
That("print (styled-segment abc &underlined=$true)(styled abc lightcyan)").Prints("\033[4mabc\033[m\033[96mabc\033[m"),
)
Test(t,
// string+text
That("print abc(styled abc blink)").Prints("abc\033[5mabc\033[m"),
// text+string
That("print (styled abc blink)abc").Prints("\033[5mabc\033[mabc"),
// text+segment
That("print (styled abc inverse)(styled-segment abc &bg-color=white)").Prints("\033[7mabc\033[m\033[107mabc\033[m"),
// text+text
That("print (styled abc bold)(styled abc dim)").Prints("\033[1mabc\033[m\033[2mabc\033[m"),
)
}
func TestFunctionalStyleTransformers(t *testing.T) {
// lambda
Test(t,
That("print (styled abc [s]{ put $s })").Prints("abc"),
That("print (styled abc [s]{ styled-segment $s &bold=$true &italic=$false })").Prints("\033[1mabc\033[m"),
That("print (styled abc italic [s]{ styled-segment $s &bold=$true &italic=$false })").Prints("\033[1mabc\033[m"),
)
// fn
Test(t,
That("fn f [s]{ put $s }; print (styled abc $f~)").Prints("abc"),
That("fn f [s]{ styled-segment $s &bold=$true &italic=$false }; print (styled abc $f~)").Prints("\033[1mabc\033[m"),
That("fn f [s]{ styled-segment $s &bold=$true &italic=$false }; print (styled abc italic $f~)").Prints("\033[1mabc\033[m"),
)
// var
Test(t,
That("f = [s]{ put $s }; print (styled abc $f)").Prints("abc"),
That("f = [s]{ styled-segment $s &bold=$true &italic=$false }; print (styled abc $f)").Prints("\033[1mabc\033[m"),
That("f = [s]{ styled-segment $s &bold=$true &italic=$false }; print (styled abc italic $f)").Prints("\033[1mabc\033[m"),
)
}
func TestStyledIndexing(t *testing.T) {
Test(t,
That("put (styled-segment abc &italic=$true &fg-color=red)[bold]").Puts(false),
That("put (styled-segment abc &italic=$true &fg-color=red)[italic]").Puts(true),
That("put (styled-segment abc &italic=$true &fg-color=red)[fg-color]").Puts("red"),
)
Test(t,
That("put (styled abc red)[0][bold]").Puts(false),
That("put (styled abc red)[0][bg-color]").Puts("default"),
That("t = (styled-segment abc &underlined=$true)(styled abc lightcyan); put $t[1][fg-color]").Puts("lightcyan"),
That("t = (styled-segment abc &underlined=$true)(styled abc lightcyan); put $t[1][underlined]").Puts(false),
)
}

View File

@ -32,23 +32,23 @@ debug-mode = $false
fn -debug [text]{ fn -debug [text]{
if $debug-mode { if $debug-mode {
print (edit:styled '=> ' blue) print (styled '=> ' blue)
echo $text echo $text
} }
} }
fn -info [text]{ fn -info [text]{
print (edit:styled '=> ' green) print (styled '=> ' green)
echo $text echo $text
} }
fn -warn [text]{ fn -warn [text]{
print (edit:styled '=> ' yellow) print (styled '=> ' yellow)
echo $text echo $text
} }
fn -error [text]{ fn -error [text]{
print (edit:styled '=> ' red) print (styled '=> ' red)
echo $text echo $text
} }
@ -257,20 +257,20 @@ fn metadata [pkg]{
fn query [pkg]{ fn query [pkg]{
data = (metadata $pkg) data = (metadata $pkg)
special-keys = [name method installed src dst] special-keys = [name method installed src dst]
echo (edit:styled "Package "$data[name] cyan) echo (styled "Package "$data[name] cyan)
if $data[installed] { if $data[installed] {
echo (edit:styled "Installed at "$data[dst] green) echo (styled "Installed at "$data[dst] green)
} else { } else {
echo (edit:styled "Not installed" red) echo (styled "Not installed" red)
} }
echo (edit:styled "Source:" blue) $data[method] $data[src] echo (styled "Source:" blue) $data[method] $data[src]
keys $data | each [key]{ keys $data | each [key]{
if (not (has-value $special-keys $key)) { if (not (has-value $special-keys $key)) {
val = $data[$key] val = $data[$key]
if (eq (kind-of $val) list) { if (eq (kind-of $val) list) {
val = (joins ", " $val) val = (joins ", " $val)
} }
echo (edit:styled (-first-upper $key)":" blue) $val echo (styled (-first-upper $key)":" blue) $val
} }
} }
} }

108
styled/segment.go Normal file
View File

@ -0,0 +1,108 @@
package styled
import (
"bytes"
"fmt"
"strings"
"github.com/elves/elvish/eval/vals"
)
// Segment is a string that has some style applied to it.
type Segment struct {
Style
Text string
}
func (Segment) Kind() string { return "styled-segment" }
// Repr returns the representation of this Segment. The string can be used to
// construct an identical Segment. Unset or default attributes are skipped. If
// the Segment represents an unstyled string only this string is returned.
func (s Segment) Repr(indent int) string {
buf := new(bytes.Buffer)
addIfNotEqual := func(key string, val, cmp interface{}) {
if val != cmp {
fmt.Fprintf(buf, "&%s=%s ", key, vals.Repr(val, 0))
}
}
addIfNotEqual("fg-color", s.Foreground, "")
addIfNotEqual("bg-color", s.Background, "")
addIfNotEqual("bold", s.Bold, false)
addIfNotEqual("dim", s.Dim, false)
addIfNotEqual("italic", s.Italic, false)
addIfNotEqual("underlined", s.Underlined, false)
addIfNotEqual("blink", s.Blink, false)
addIfNotEqual("inverse", s.Inverse, false)
if buf.Len() == 0 {
return s.Text
}
return fmt.Sprintf("(styled-segment %s %s)", s.Text, strings.TrimSpace(buf.String()))
}
func (s Segment) IterateKeys(fn func(v interface{}) bool) {
vals.Feed(fn, "text", "fg-color", "bg-color", "bold", "dim", "italic", "underlined", "blink", "inverse")
}
// Index provides access to the attributes of the Segment.
func (s Segment) Index(k interface{}) (v interface{}, ok bool) {
switch k {
case "text":
v = s.Text
case "fg-color":
v = s.Foreground
case "bg-color":
v = s.Background
case "bold":
v = s.Bold
case "dim":
v = s.Dim
case "italic":
v = s.Italic
case "underlined":
v = s.Underlined
case "blink":
v = s.Blink
case "inverse":
v = s.Inverse
}
if v == "" {
v = "default"
}
return v, v != nil
}
// Concat implements Segment+string, Segment+Segment and Segment+Text.
func (s Segment) Concat(v interface{}) (interface{}, error) {
switch rhs := v.(type) {
case string:
return Text{
s,
Segment{Text: rhs},
}, nil
case *Segment:
return Text{s, *rhs}, nil
case *Text:
return Text(append([]Segment{s}, *rhs...)), nil
}
return nil, vals.ErrConcatNotImplemented
}
// RConcat implements string+Segment.
func (s Segment) RConcat(v interface{}) (interface{}, error) {
switch lhs := v.(type) {
case string:
return Text{
Segment{Text: lhs},
s,
}, nil
}
return nil, vals.ErrConcatNotImplemented
}

101
styled/styled.go Normal file
View File

@ -0,0 +1,101 @@
package styled
import (
"fmt"
)
// Style specifies how something (mostly a string) shall be displayed.
type Style struct {
Foreground string
Background string
Bold bool
Dim bool
Italic bool
Underlined bool
Blink bool
Inverse bool
}
// ImportFromOptions assigns all recognized values from a map to the current
// Style.
func (s *Style) ImportFromOptions(options map[string]interface{}) error {
assignColor := func(key string, colorField *string) error {
if c, ok := options[key]; ok {
if c, ok := c.(string); ok && isValidColorName(c) {
if c == "default" {
*colorField = ""
} else {
*colorField = c
}
} else {
return fmt.Errorf("value to option '%s' must be a valid color string", key)
}
}
return nil
}
assignBool := func(key string, attrField *bool) error {
if b, ok := options[key]; ok {
if b, ok := b.(bool); ok {
*attrField = b
} else {
return fmt.Errorf("value to option '%s' must be a bool value", key)
}
}
return nil
}
if err := assignColor("fg-color", &s.Foreground); err != nil {
return err
}
if err := assignColor("bg-color", &s.Background); err != nil {
return err
}
if err := assignBool("bold", &s.Bold); err != nil {
return err
}
if err := assignBool("dim", &s.Dim); err != nil {
return err
}
if err := assignBool("italic", &s.Italic); err != nil {
return err
}
if err := assignBool("underlined", &s.Underlined); err != nil {
return err
}
if err := assignBool("blink", &s.Blink); err != nil {
return err
}
if err := assignBool("inverse", &s.Inverse); err != nil {
return err
}
return nil
}
func isValidColorName(col string) bool {
switch col {
case
"default",
"black",
"red",
"green",
"yellow",
"blue",
"magenta",
"cyan",
"lightgray",
"gray",
"lightred",
"lightgreen",
"lightyellow",
"lightblue",
"lightmagenta",
"lightcyan",
"white":
return true
default:
return false
}
}

68
styled/text.go Normal file
View File

@ -0,0 +1,68 @@
package styled
import (
"bytes"
"fmt"
"strconv"
"github.com/elves/elvish/eval/vals"
)
// Text contains of a list of styled Segments.
type Text []Segment
func (t Text) Kind() string { return "styled-text" }
// Repr returns the representation of the current Text. It is just a wrapper
// around the containing Segments.
func (t Text) Repr(indent int) string {
buf := new(bytes.Buffer)
for _, s := range t {
buf.WriteString(s.Repr(indent + 1))
}
return fmt.Sprintf("(styled %s)", buf.String())
}
func (t Text) IterateKeys(fn func(interface{}) bool) {
for i := 0; i < len(t); i++ {
if !fn(strconv.Itoa(i)) {
break
}
}
}
// Index provides access to the underlying Segments.
func (t Text) Index(k interface{}) (interface{}, error) {
index, err := vals.ConvertListIndex(k, len(t))
if err != nil {
return nil, err
} else if index.Slice {
return t[index.Lower:index.Upper], nil
} else {
return t[index.Lower], nil
}
}
// Concat implements Text+string, Text+Segment and Text+Text.
func (t Text) Concat(v interface{}) (interface{}, error) {
switch rhs := v.(type) {
case string:
return Text(append(t, Segment{Text: rhs})), nil
case *Segment:
return Text(append(t, *rhs)), nil
case *Text:
return Text(append(t, *rhs...)), nil
}
return nil, vals.ErrConcatNotImplemented
}
// RConcat implements string+Text.
func (t Text) RConcat(v interface{}) (interface{}, error) {
switch lhs := v.(type) {
case string:
return Text(append([]Segment{{Text: lhs}}, t...)), nil
}
return nil, vals.ErrConcatNotImplemented
}

91
styled/to_string.go Normal file
View File

@ -0,0 +1,91 @@
package styled
import (
"strings"
)
// todo: Make string conversion variable to environment.
// E.g. the shell displays colors different than HTML.
func (t Text) String() string {
buf := make([]byte, 0, 64)
for _, segment := range t {
styleBuf := make([]string, 0, 8)
if segment.Bold {
styleBuf = append(styleBuf, "1")
}
if segment.Dim {
styleBuf = append(styleBuf, "2")
}
if segment.Italic {
styleBuf = append(styleBuf, "3")
}
if segment.Underlined {
styleBuf = append(styleBuf, "4")
}
if segment.Blink {
styleBuf = append(styleBuf, "5")
}
if segment.Inverse {
styleBuf = append(styleBuf, "7")
}
if segment.Foreground != "" {
if col, ok := colorTranslationTable[segment.Foreground]; ok {
styleBuf = append(styleBuf, col)
}
}
if segment.Background != "" {
if col, ok := colorTranslationTable["bg-"+segment.Background]; ok {
styleBuf = append(styleBuf, col)
}
}
if len(styleBuf) > 0 {
buf = append(buf, "\033["...)
buf = append(buf, strings.Join(styleBuf, ";")...)
buf = append(buf, 'm')
buf = append(buf, segment.Text...)
buf = append(buf, "\033[m"...)
} else {
buf = append(buf, segment.Text...)
}
}
return string(buf)
}
var colorTranslationTable = map[string]string{
"black": "30",
"red": "31",
"green": "32",
"yellow": "33",
"blue": "34",
"magenta": "35",
"cyan": "36",
"lightgray": "37",
"gray": "90",
"lightred": "91",
"lightgreen": "92",
"lightyellow": "93",
"lightblue": "94",
"lightmagenta": "95",
"lightcyan": "96",
"white": "97",
"bg-black": "40",
"bg-red": "41",
"bg-green": "42",
"bg-yellow": "43",
"bg-blue": "44",
"bg-magenta": "45",
"bg-cyan": "46",
"bg-lightgray": "47",
"bg-gray": "100",
"bg-lightred": "101",
"bg-lightgreen": "102",
"bg-lightyellow": "103",
"bg-lightblue": "104",
"bg-lightmagenta": "105",
"bg-lightcyan": "106",
"bg-white": "107",
}

91
styled/transform.go Normal file
View File

@ -0,0 +1,91 @@
package styled
import (
"strings"
)
// FindTransformer looks up a transformer name and if successful returns a
// function that can be used to transform a styled Segment.
func FindTransformer(transformerName string) func(Segment) Segment {
var innerTransformer func(*Segment)
switch {
// Catch special colors early
case transformerName == "default":
innerTransformer = func(s *Segment) { s.Foreground = "" }
case transformerName == "bg-default":
innerTransformer = func(s *Segment) { s.Background = "" }
case strings.HasPrefix(transformerName, "bg-"):
innerTransformer = buildColorTransformer(strings.TrimPrefix(transformerName, "bg-"), false)
case strings.HasPrefix(transformerName, "no-"):
innerTransformer = buildBoolTransformer(strings.TrimPrefix(transformerName, "no-"), false, false)
case strings.HasPrefix(transformerName, "toggle-"):
innerTransformer = buildBoolTransformer(strings.TrimPrefix(transformerName, "toggle-"), false, true)
default:
innerTransformer = buildColorTransformer(transformerName, true)
if innerTransformer == nil {
innerTransformer = buildBoolTransformer(transformerName, true, false)
}
}
if innerTransformer == nil {
return nil
}
return func(segment Segment) Segment {
innerTransformer(&segment)
return segment
}
}
func buildColorTransformer(transformerName string, setForeground bool) func(*Segment) {
if isValidColorName(transformerName) {
if setForeground {
return func(s *Segment) { s.Foreground = transformerName }
} else {
return func(s *Segment) { s.Background = transformerName }
}
}
return nil
}
func buildBoolTransformer(transformerName string, val, toggle bool) func(*Segment) {
switch transformerName {
case "bold":
if toggle {
return func(s *Segment) { s.Bold = !s.Bold }
}
return func(s *Segment) { s.Bold = val }
case "dim":
if toggle {
return func(s *Segment) { s.Dim = !s.Dim }
}
return func(s *Segment) { s.Dim = val }
case "italic":
if toggle {
return func(s *Segment) { s.Italic = !s.Italic }
}
return func(s *Segment) { s.Italic = val }
case "underlined":
if toggle {
return func(s *Segment) { s.Underlined = !s.Underlined }
}
return func(s *Segment) { s.Underlined = val }
case "blink":
if toggle {
return func(s *Segment) { s.Blink = !s.Blink }
}
return func(s *Segment) { s.Blink = val }
case "inverse":
if toggle {
return func(s *Segment) { s.Inverse = !s.Inverse }
}
return func(s *Segment) { s.Inverse = val }
}
return nil
}