Projects STRLCPY termdash Commits 4a656916
🤬
Revision indexing in progress... (symbol navigation in revisions will be accurate after indexed)
  • ■ ■ ■ ■ ■
    README.md
    skipped 70 lines
    71 71   
    72 72  ### The Gauge
    73 73   
    74  -Run the [gaugedemo](widgets/gauge/demo/gaugedemo.go).
     74 +Displays the progress of an operation. Run the
     75 +[gaugedemo](widgets/gauge/demo/gaugedemo.go).
    75 76   
    76  -[<img src="./images/gaugedemo.gif" alt="gaugedemo" type="image/png" width="100%">](widgets/gauge/demo/gaugedemo.go)
     77 +[<img src="./images/gaugedemo.gif" alt="gaugedemo" type="image/gif">](widgets/gauge/demo/gaugedemo.go)
     78 + 
     79 +### The Text
     80 + 
     81 +[<img src="./images/textdemo.gif" alt="gaugedemo" type="image/gif">](widgets/gauge/demo/gaugedemo.go)
     82 + 
     83 +Displays text content, supports trimming and scrolling of content. Run the
     84 +[textdemo](widgets/text/demo/textdemo.go).
    77 85   
    78 86  ## Disclaimer
    79 87   
    skipped 2 lines
  • ■ ■ ■ ■ ■ ■
    canvas/canvas.go
    skipped 71 lines
    72 72   return nil
    73 73  }
    74 74   
    75  -// SetCell sets the value of the specified cell on the canvas.
     75 +// SetCell sets the rune of the specified cell on the canvas. Returns the
     76 +// number of cells the rune occupies, wide runes can occupy multiple cells when
     77 +// printed on the terminal. See http://www.unicode.org/reports/tr11/.
    76 78  // Use the options to specify which attributes to modify, if an attribute
    77 79  // option isn't specified, the attribute retains its previous value.
    78  -func (c *Canvas) SetCell(p image.Point, r rune, opts ...cell.Option) error {
    79  - ar, err := area.FromSize(c.buffer.Size())
     80 +func (c *Canvas) SetCell(p image.Point, r rune, opts ...cell.Option) (int, error) {
     81 + return c.buffer.SetCell(p, r, opts...)
     82 +}
     83 + 
     84 +// Cell returns a copy of the specified cell.
     85 +func (c *Canvas) Cell(p image.Point) (*cell.Cell, error) {
     86 + ar, err := area.FromSize(c.Size())
    80 87   if err != nil {
    81  - return err
     88 + return nil, err
    82 89   }
    83 90   if !p.In(ar) {
    84  - return fmt.Errorf("cell at point %+v falls out of the canvas area %+v", p, ar)
     91 + return nil, fmt.Errorf("point %v falls outside of the area %v occupied by the canvas", p, ar)
    85 92   }
    86 93   
    87  - cell := c.buffer[p.X][p.Y]
    88  - cell.Rune = r
    89  - cell.Apply(opts...)
    90  - return nil
     94 + return c.buffer[p.X][p.Y].Copy(), nil
    91 95  }
    92 96   
    93 97  // Apply applies the canvas to the corresponding area of the terminal.
    skipped 15 lines
    109 113   
    110 114   for col := range c.buffer {
    111 115   for row := range c.buffer[col] {
     116 + partial, err := c.buffer.IsPartial(image.Point{col, row})
     117 + if err != nil {
     118 + return err
     119 + }
     120 + if partial {
     121 + // Skip over partial cells, i.e. cells that follow a cell
     122 + // containing a full-width rune. A full-width rune takes only
     123 + // one cell in the buffer, but two on the terminal.
     124 + // See http://www.unicode.org/reports/tr11/.
     125 + continue
     126 + }
    112 127   cell := c.buffer[col][row]
    113 128   // The image.Point{0, 0} of this canvas isn't always exactly at
    114 129   // image.Point{0, 0} on the terminal.
    skipped 11 lines
  • ■ ■ ■ ■ ■ ■
    canvas/canvas_test.go
    skipped 18 lines
    19 19   "testing"
    20 20   
    21 21   "github.com/kylelemons/godebug/pretty"
     22 + "github.com/mum4k/termdash/area"
    22 23   "github.com/mum4k/termdash/cell"
    23 24   "github.com/mum4k/termdash/terminal/faketerm"
    24 25  )
    skipped 83 lines
    108 109   r rune
    109 110   opts []cell.Option
    110 111   want cell.Buffer // Expected back buffer in the fake terminal.
     112 + wantCells int
    111 113   wantSetCellErr bool
    112 114   wantApplyErr bool
    113 115   }{
    skipped 10 lines
    124 126   canvasArea: image.Rect(1, 1, 3, 3),
    125 127   point: image.Point{0, 0},
    126 128   r: 'X',
     129 + wantCells: 1,
    127 130   want: cell.Buffer{
    128 131   {
    129 132   cell.New(0),
    skipped 13 lines
    143 146   },
    144 147   },
    145 148   {
     149 + desc: "sets a full-width rune in the top-left corner cell",
     150 + termSize: image.Point{3, 3},
     151 + canvasArea: image.Rect(1, 1, 3, 3),
     152 + point: image.Point{0, 0},
     153 + r: '界',
     154 + wantCells: 2,
     155 + want: cell.Buffer{
     156 + {
     157 + cell.New(0),
     158 + cell.New(0),
     159 + cell.New(0),
     160 + },
     161 + {
     162 + cell.New(0),
     163 + cell.New('界'),
     164 + cell.New(0),
     165 + },
     166 + {
     167 + cell.New(0),
     168 + cell.New(0),
     169 + cell.New(0),
     170 + },
     171 + },
     172 + },
     173 + {
     174 + desc: "not enough space for a full-width rune",
     175 + termSize: image.Point{3, 3},
     176 + canvasArea: image.Rect(1, 1, 3, 3),
     177 + point: image.Point{1, 0},
     178 + r: '界',
     179 + wantSetCellErr: true,
     180 + },
     181 + {
    146 182   desc: "sets a top-right corner cell",
    147 183   termSize: image.Point{3, 3},
    148 184   canvasArea: image.Rect(1, 1, 3, 3),
    149 185   point: image.Point{1, 0},
    150 186   r: 'X',
     187 + wantCells: 1,
    151 188   want: cell.Buffer{
    152 189   {
    153 190   cell.New(0),
    skipped 18 lines
    172 209   canvasArea: image.Rect(1, 1, 3, 3),
    173 210   point: image.Point{0, 1},
    174 211   r: 'X',
     212 + wantCells: 1,
    175 213   want: cell.Buffer{
    176 214   {
    177 215   cell.New(0),
    skipped 18 lines
    196 234   canvasArea: image.Rect(1, 1, 3, 3),
    197 235   point: image.Point{1, 1},
    198 236   r: 'Z',
     237 + wantCells: 1,
    199 238   want: cell.Buffer{
    200 239   {
    201 240   cell.New(0),
    skipped 21 lines
    223 262   opts: []cell.Option{
    224 263   cell.BgColor(cell.ColorRed),
    225 264   },
     265 + wantCells: 1,
    226 266   want: cell.Buffer{
    227 267   {
    228 268   cell.New(0),
    skipped 18 lines
    247 287   canvasArea: image.Rect(0, 0, 1, 1),
    248 288   point: image.Point{0, 0},
    249 289   r: 'A',
     290 + wantCells: 1,
    250 291   want: cell.Buffer{
    251 292   {
    252 293   cell.New('A'),
    skipped 6 lines
    259 300   canvasArea: image.Rect(0, 0, 2, 2),
    260 301   point: image.Point{0, 0},
    261 302   r: 'A',
     303 + wantCells: 1,
    262 304   wantApplyErr: true,
    263 305   },
    264 306   }
    skipped 5 lines
    270 312   t.Fatalf("New => unexpected error: %v", err)
    271 313   }
    272 314   
    273  - err = c.SetCell(tc.point, tc.r, tc.opts...)
     315 + gotCells, err := c.SetCell(tc.point, tc.r, tc.opts...)
    274 316   if (err != nil) != tc.wantSetCellErr {
    275 317   t.Errorf("SetCell => unexpected error: %v, wantSetCellErr: %v", err, tc.wantSetCellErr)
    276 318   }
    skipped 1 lines
    278 320   return
    279 321   }
    280 322   
     323 + if gotCells != tc.wantCells {
     324 + t.Errorf("SetCell => unexpected number of cells %d, want %d", gotCells, tc.wantCells)
     325 + }
     326 + 
    281 327   ft, err := faketerm.New(tc.termSize)
    282 328   if err != nil {
    283 329   t.Fatalf("faketerm.New => unexpected error: %v", err)
    skipped 20 lines
    304 350   t.Fatalf("New => unexpected error: %v", err)
    305 351   }
    306 352   
    307  - if err := c.SetCell(image.Point{0, 0}, 'X'); err != nil {
     353 + if _, err := c.SetCell(image.Point{0, 0}, 'X'); err != nil {
    308 354   t.Fatalf("SetCell => unexpected error: %v", err)
    309 355   }
    310 356   
    skipped 65 lines
    376 422   }
    377 423  }
    378 424   
     425 +// TestApplyFullWidthRunes verifies that when applying a full-width rune to the
     426 +// terminal, canvas doesn't touch the neighbor cell that holds the remaining
     427 +// part of the full-width rune.
     428 +func TestApplyFullWidthRunes(t *testing.T) {
     429 + ar := image.Rect(0, 0, 3, 3)
     430 + c, err := New(ar)
     431 + if err != nil {
     432 + t.Fatalf("New => unexpected error: %v", err)
     433 + }
     434 + 
     435 + fullP := image.Point{0, 0}
     436 + if _, err := c.SetCell(fullP, '界'); err != nil {
     437 + t.Fatalf("SetCell => unexpected error: %v", err)
     438 + }
     439 + 
     440 + ft, err := faketerm.New(area.Size(ar))
     441 + if err != nil {
     442 + t.Fatalf("faketerm.New => unexpected error: %v", err)
     443 + }
     444 + partP := image.Point{1, 0}
     445 + if err := ft.SetCell(partP, 'A'); err != nil {
     446 + t.Fatalf("faketerm.SetCell => unexpected error: %v", err)
     447 + }
     448 + 
     449 + if err := c.Apply(ft); err != nil {
     450 + t.Fatalf("Apply => unexpected error: %v", err)
     451 + }
     452 + 
     453 + want, err := cell.NewBuffer(area.Size(ar))
     454 + if err != nil {
     455 + t.Fatalf("NewBuffer => unexpected error: %v", err)
     456 + }
     457 + want[fullP.X][fullP.Y].Rune = '界'
     458 + want[partP.X][partP.Y].Rune = 'A'
     459 + 
     460 + got := ft.BackBuffer()
     461 + if diff := pretty.Compare(want, got); diff != "" {
     462 + t.Errorf("faketerm.BackBuffer => unexpected diff (-want, +got):\n%s", diff)
     463 + }
     464 +}
     465 + 
     466 +func TestCell(t *testing.T) {
     467 + tests := []struct {
     468 + desc string
     469 + cvs func() (*Canvas, error)
     470 + point image.Point
     471 + want *cell.Cell
     472 + wantErr bool
     473 + }{
     474 + {
     475 + desc: "requested point falls outside of the canvas",
     476 + cvs: func() (*Canvas, error) {
     477 + cvs, err := New(image.Rect(0, 0, 1, 1))
     478 + if err != nil {
     479 + return nil, err
     480 + }
     481 + return cvs, nil
     482 + },
     483 + point: image.Point{1, 1},
     484 + wantErr: true,
     485 + },
     486 + {
     487 + desc: "returns the cell",
     488 + cvs: func() (*Canvas, error) {
     489 + cvs, err := New(image.Rect(0, 0, 2, 2))
     490 + if err != nil {
     491 + return nil, err
     492 + }
     493 + if _, err := cvs.SetCell(
     494 + image.Point{1, 1}, 'A',
     495 + cell.FgColor(cell.ColorRed),
     496 + cell.BgColor(cell.ColorBlue),
     497 + ); err != nil {
     498 + return nil, err
     499 + }
     500 + return cvs, nil
     501 + },
     502 + point: image.Point{1, 1},
     503 + want: &cell.Cell{
     504 + Rune: 'A',
     505 + Opts: cell.NewOptions(
     506 + cell.FgColor(cell.ColorRed),
     507 + cell.BgColor(cell.ColorBlue),
     508 + ),
     509 + },
     510 + },
     511 + }
     512 + 
     513 + for _, tc := range tests {
     514 + t.Run(tc.desc, func(t *testing.T) {
     515 + cvs, err := tc.cvs()
     516 + if err != nil {
     517 + t.Fatalf("tc.cvs => unexpected error: %v", err)
     518 + }
     519 + 
     520 + got, err := cvs.Cell(tc.point)
     521 + if (err != nil) != tc.wantErr {
     522 + t.Errorf("Cell => unexpected error: %v, wantErr: %v", err, tc.wantErr)
     523 + }
     524 + if err != nil {
     525 + return
     526 + }
     527 + 
     528 + if diff := pretty.Compare(tc.want, got); diff != "" {
     529 + t.Errorf("Cell => unexpected diff (-want, +got):\n%s", diff)
     530 + }
     531 + })
     532 + }
     533 +}
     534 + 
  • ■ ■ ■ ■ ■ ■
    canvas/testcanvas/testcanvas.go
    skipped 39 lines
    40 40   }
    41 41  }
    42 42   
    43  -// MustSetCell sets the cell value or panics.
    44  -func MustSetCell(c *canvas.Canvas, p image.Point, r rune, opts ...cell.Option) {
    45  - if err := c.SetCell(p, r, opts...); err != nil {
     43 +// MustSetCell sets the cell value or panics. Returns the number of cells the
     44 +// rune occupies, wide runes can occupy multiple cells when printed on the
     45 +// terminal. See http://www.unicode.org/reports/tr11/.
     46 +func MustSetCell(c *canvas.Canvas, p image.Point, r rune, opts ...cell.Option) int {
     47 + cells, err := c.SetCell(p, r, opts...)
     48 + if err != nil {
    46 49   panic(fmt.Sprintf("canvas.SetCell => unexpected error: %v", err))
    47 50   }
     51 + return cells
    48 52  }
    49 53   
  • ■ ■ ■ ■ ■ ■
    cell/cell.go
    skipped 21 lines
    22 22  import (
    23 23   "fmt"
    24 24   "image"
     25 + 
     26 + runewidth "github.com/mattn/go-runewidth"
     27 + "github.com/mum4k/termdash/area"
    25 28  )
    26 29   
    27 30  // Option is used to provide options for cells on a 2-D terminal.
    skipped 31 lines
    59 62   Opts *Options
    60 63  }
    61 64   
     65 +// Copy returns a copy the cell.
     66 +func (c *Cell) Copy() *Cell {
     67 + return &Cell{
     68 + Rune: c.Rune,
     69 + Opts: NewOptions(c.Opts),
     70 + }
     71 +}
     72 + 
    62 73  // New returns a new cell.
    63 74  func New(r rune, opts ...Option) *Cell {
    64 75   return &Cell{
    skipped 11 lines
    76 87   
    77 88  // Buffer is a 2-D buffer of cells.
    78 89  // The axes increase right and down.
     90 +// Uninitialized buffer is invalid, use NewBuffer to create an instance.
     91 +// Don't set cells directly, use the SetCell method instead which safely
     92 +// handles limits and wide unicode characters.
    79 93  type Buffer [][]*Cell
     94 + 
     95 +// SetCell sets the rune of the specified cell in the buffer. Returns the
     96 +// number of cells the rune occupies, wide runes can occupy multiple cells when
     97 +// printed on the terminal. See http://www.unicode.org/reports/tr11/.
     98 +// Use the options to specify which attributes to modify, if an attribute
     99 +// option isn't specified, the attribute retains its previous value.
     100 +func (b Buffer) SetCell(p image.Point, r rune, opts ...Option) (int, error) {
     101 + partial, err := b.IsPartial(p)
     102 + if err != nil {
     103 + return -1, err
     104 + }
     105 + if partial {
     106 + return -1, fmt.Errorf("cannot set rune %q at point %v, it is a partial cell occupied by a wide rune in the previous cell", r, p)
     107 + }
     108 + 
     109 + remW, err := b.RemWidth(p)
     110 + if err != nil {
     111 + return -1, err
     112 + }
     113 + rw := runewidth.RuneWidth(r)
     114 + if rw > remW {
     115 + return -1, fmt.Errorf("cannot set rune %q of width %d at point %v, only have %d remaining cells at this line", r, rw, p, remW)
     116 + }
     117 + 
     118 + cell := b[p.X][p.Y]
     119 + cell.Rune = r
     120 + cell.Apply(opts...)
     121 + return rw, nil
     122 +}
     123 + 
     124 +// IsPartial returns true if the cell at the specified point holds a part of a
     125 +// full width rune from a previous cell. See
     126 +// http://www.unicode.org/reports/tr11/.
     127 +func (b Buffer) IsPartial(p image.Point) (bool, error) {
     128 + size := b.Size()
     129 + ar, err := area.FromSize(size)
     130 + if err != nil {
     131 + return false, err
     132 + }
     133 + 
     134 + if !p.In(ar) {
     135 + return false, fmt.Errorf("point %v falls outside of the area %v occupied by the buffer", p, ar)
     136 + }
     137 + 
     138 + if p.X == 0 && p.Y == 0 {
     139 + return false, nil
     140 + }
     141 + 
     142 + prevP := image.Point{p.X - 1, p.Y}
     143 + if prevP.X < 0 {
     144 + prevP = image.Point{size.X - 1, p.Y - 1}
     145 + }
     146 + 
     147 + prevR := b[prevP.X][prevP.Y].Rune
     148 + switch rw := runewidth.RuneWidth(prevR); rw {
     149 + case 0, 1:
     150 + return false, nil
     151 + case 2:
     152 + return true, nil
     153 + default:
     154 + return false, fmt.Errorf("buffer cell %v contains rune %q which has an unsupported rune with %d", prevP, prevR, rw)
     155 + }
     156 +}
     157 + 
     158 +// RemWidth returns the remaining width (horizontal row of cells) available
     159 +// from and inclusive of the specified point.
     160 +func (b Buffer) RemWidth(p image.Point) (int, error) {
     161 + size := b.Size()
     162 + ar, err := area.FromSize(size)
     163 + if err != nil {
     164 + return -1, err
     165 + }
     166 + 
     167 + if !p.In(ar) {
     168 + return -1, fmt.Errorf("point %v falls outside of the area %v occupied by the buffer", p, ar)
     169 + }
     170 + return size.X - p.X, nil
     171 +}
    80 172   
    81 173  // NewBuffer returns a new Buffer of the provided size.
    82 174  func NewBuffer(size image.Point) (Buffer, error) {
    skipped 47 lines
  • ■ ■ ■ ■ ■ ■
    cell/cell_test.go
    skipped 267 lines
    268 268   }
    269 269  }
    270 270   
     271 +// mustNewBuffer returns a new Buffer or panics.
     272 +func mustNewBuffer(size image.Point) Buffer {
     273 + b, err := NewBuffer(size)
     274 + if err != nil {
     275 + panic(err)
     276 + }
     277 + return b
     278 +}
     279 + 
     280 +func TestSetCell(t *testing.T) {
     281 + size := image.Point{3, 3}
     282 + tests := []struct {
     283 + desc string
     284 + buffer Buffer
     285 + point image.Point
     286 + r rune
     287 + opts []Option
     288 + wantCells int
     289 + want Buffer
     290 + wantErr bool
     291 + }{
     292 + {
     293 + desc: "point falls before the buffer",
     294 + buffer: mustNewBuffer(size),
     295 + point: image.Point{-1, -1},
     296 + r: 'A',
     297 + wantErr: true,
     298 + },
     299 + {
     300 + desc: "point falls after the buffer",
     301 + buffer: mustNewBuffer(size),
     302 + point: image.Point{3, 3},
     303 + r: 'A',
     304 + wantErr: true,
     305 + },
     306 + {
     307 + desc: "point falls on cell with partial rune",
     308 + buffer: func() Buffer {
     309 + b := mustNewBuffer(size)
     310 + b[0][0].Rune = '世'
     311 + return b
     312 + }(),
     313 + point: image.Point{1, 0},
     314 + r: 'A',
     315 + wantErr: true,
     316 + },
     317 + {
     318 + desc: "point falls on cell with full-width rune and overwrites with half-width rune",
     319 + buffer: func() Buffer {
     320 + b := mustNewBuffer(size)
     321 + b[0][0].Rune = '世'
     322 + return b
     323 + }(),
     324 + point: image.Point{0, 0},
     325 + r: 'A',
     326 + wantCells: 1,
     327 + want: func() Buffer {
     328 + b := mustNewBuffer(size)
     329 + b[0][0].Rune = 'A'
     330 + return b
     331 + }(),
     332 + },
     333 + {
     334 + desc: "point falls on cell with full-width rune and overwrites with full-width rune",
     335 + buffer: func() Buffer {
     336 + b := mustNewBuffer(size)
     337 + b[0][0].Rune = '世'
     338 + return b
     339 + }(),
     340 + point: image.Point{0, 0},
     341 + r: '界',
     342 + wantCells: 2,
     343 + want: func() Buffer {
     344 + b := mustNewBuffer(size)
     345 + b[0][0].Rune = '界'
     346 + return b
     347 + }(),
     348 + },
     349 + {
     350 + desc: "not enough space for a wide rune on the line",
     351 + buffer: mustNewBuffer(image.Point{3, 3}),
     352 + point: image.Point{2, 0},
     353 + r: '界',
     354 + wantErr: true,
     355 + },
     356 + {
     357 + desc: "sets half-width rune in a cell",
     358 + buffer: mustNewBuffer(image.Point{3, 3}),
     359 + point: image.Point{1, 1},
     360 + r: 'A',
     361 + wantCells: 1,
     362 + want: func() Buffer {
     363 + b := mustNewBuffer(size)
     364 + b[1][1].Rune = 'A'
     365 + return b
     366 + }(),
     367 + },
     368 + {
     369 + desc: "sets full-width rune in a cell",
     370 + buffer: mustNewBuffer(image.Point{3, 3}),
     371 + point: image.Point{1, 2},
     372 + r: '界',
     373 + wantCells: 2,
     374 + want: func() Buffer {
     375 + b := mustNewBuffer(size)
     376 + b[1][2].Rune = '界'
     377 + return b
     378 + }(),
     379 + },
     380 + {
     381 + desc: "sets cell options",
     382 + buffer: mustNewBuffer(image.Point{3, 3}),
     383 + point: image.Point{1, 2},
     384 + r: 'A',
     385 + opts: []Option{
     386 + FgColor(ColorRed),
     387 + BgColor(ColorBlue),
     388 + },
     389 + wantCells: 1,
     390 + want: func() Buffer {
     391 + b := mustNewBuffer(size)
     392 + cell := b[1][2]
     393 + cell.Rune = 'A'
     394 + cell.Opts = NewOptions(FgColor(ColorRed), BgColor(ColorBlue))
     395 + return b
     396 + }(),
     397 + },
     398 + {
     399 + desc: "overwrites only provided options",
     400 + buffer: func() Buffer {
     401 + b := mustNewBuffer(size)
     402 + cell := b[1][2]
     403 + cell.Opts = NewOptions(BgColor(ColorBlue))
     404 + return b
     405 + }(),
     406 + point: image.Point{1, 2},
     407 + r: 'A',
     408 + opts: []Option{
     409 + FgColor(ColorRed),
     410 + },
     411 + wantCells: 1,
     412 + want: func() Buffer {
     413 + b := mustNewBuffer(size)
     414 + cell := b[1][2]
     415 + cell.Rune = 'A'
     416 + cell.Opts = NewOptions(FgColor(ColorRed), BgColor(ColorBlue))
     417 + return b
     418 + }(),
     419 + },
     420 + }
     421 + 
     422 + for _, tc := range tests {
     423 + t.Run(tc.desc, func(t *testing.T) {
     424 + gotCells, err := tc.buffer.SetCell(tc.point, tc.r, tc.opts...)
     425 + if (err != nil) != tc.wantErr {
     426 + t.Errorf("SetCell => unexpected error: %v, wantErr: %v", err, tc.wantErr)
     427 + }
     428 + if err != nil {
     429 + return
     430 + }
     431 + 
     432 + if gotCells != tc.wantCells {
     433 + t.Errorf("SetCell => unexpected cell count, got %d, want %d", gotCells, tc.wantCells)
     434 + }
     435 + 
     436 + got := tc.buffer
     437 + if diff := pretty.Compare(tc.want, got); diff != "" {
     438 + t.Errorf("SetCell=> unexpected buffer, diff (-want, +got):\n%s", diff)
     439 + }
     440 + })
     441 + }
     442 +}
     443 + 
     444 +func TestIsPartial(t *testing.T) {
     445 + tests := []struct {
     446 + desc string
     447 + buffer Buffer
     448 + point image.Point
     449 + want bool
     450 + wantErr bool
     451 + }{
     452 + {
     453 + desc: "point falls before the buffer",
     454 + buffer: mustNewBuffer(image.Point{1, 1}),
     455 + point: image.Point{-1, -1},
     456 + wantErr: true,
     457 + },
     458 + {
     459 + desc: "point falls after the buffer",
     460 + buffer: mustNewBuffer(image.Point{1, 1}),
     461 + point: image.Point{1, 1},
     462 + wantErr: true,
     463 + },
     464 + {
     465 + desc: "the first cell cannot be partial",
     466 + buffer: mustNewBuffer(image.Point{1, 1}),
     467 + point: image.Point{0, 0},
     468 + want: false,
     469 + },
     470 + {
     471 + desc: "previous cell on the same line contains no rune",
     472 + buffer: mustNewBuffer(image.Point{3, 3}),
     473 + point: image.Point{1, 0},
     474 + want: false,
     475 + },
     476 + {
     477 + desc: "previous cell on the same line contains half-width rune",
     478 + buffer: func() Buffer {
     479 + b := mustNewBuffer(image.Point{3, 3})
     480 + b[0][0].Rune = 'A'
     481 + return b
     482 + }(),
     483 + point: image.Point{1, 0},
     484 + want: false,
     485 + },
     486 + {
     487 + desc: "previous cell on the same line contains full-width rune",
     488 + buffer: func() Buffer {
     489 + b := mustNewBuffer(image.Point{3, 3})
     490 + b[0][0].Rune = '世'
     491 + return b
     492 + }(),
     493 + point: image.Point{1, 0},
     494 + want: true,
     495 + },
     496 + {
     497 + desc: "previous cell on previous line contains no rune",
     498 + buffer: mustNewBuffer(image.Point{3, 3}),
     499 + point: image.Point{0, 1},
     500 + want: false,
     501 + },
     502 + {
     503 + desc: "previous cell on previous line contains half-width rune",
     504 + buffer: func() Buffer {
     505 + b := mustNewBuffer(image.Point{3, 3})
     506 + b[2][0].Rune = 'A'
     507 + return b
     508 + }(),
     509 + point: image.Point{0, 1},
     510 + want: false,
     511 + },
     512 + {
     513 + desc: "previous cell on previous line contains full-width rune",
     514 + buffer: func() Buffer {
     515 + b := mustNewBuffer(image.Point{3, 3})
     516 + b[2][0].Rune = '世'
     517 + return b
     518 + }(),
     519 + point: image.Point{0, 1},
     520 + want: true,
     521 + },
     522 + }
     523 + 
     524 + for _, tc := range tests {
     525 + t.Run(tc.desc, func(t *testing.T) {
     526 + got, err := tc.buffer.IsPartial(tc.point)
     527 + if (err != nil) != tc.wantErr {
     528 + t.Errorf("IsPartial => unexpected error: %v, wantErr: %v", err, tc.wantErr)
     529 + }
     530 + if err != nil {
     531 + return
     532 + }
     533 + 
     534 + if got != tc.want {
     535 + t.Errorf("IsPartial => got %v, want %v", got, tc.want)
     536 + }
     537 + })
     538 + }
     539 +}
     540 + 
     541 +func TestRemWidth(t *testing.T) {
     542 + tests := []struct {
     543 + desc string
     544 + size image.Point
     545 + point image.Point
     546 + want int
     547 + wantErr bool
     548 + }{
     549 + {
     550 + desc: "point falls before the buffer",
     551 + size: image.Point{1, 1},
     552 + point: image.Point{-1, -1},
     553 + wantErr: true,
     554 + },
     555 + {
     556 + desc: "point falls after the buffer",
     557 + size: image.Point{1, 1},
     558 + point: image.Point{1, 1},
     559 + wantErr: true,
     560 + },
     561 + {
     562 + desc: "remaining width from the first cell on the line",
     563 + size: image.Point{3, 3},
     564 + point: image.Point{0, 1},
     565 + want: 3,
     566 + },
     567 + {
     568 + desc: "remaining width from the last cell on the line",
     569 + size: image.Point{3, 3},
     570 + point: image.Point{2, 2},
     571 + want: 1,
     572 + },
     573 + }
     574 + 
     575 + for _, tc := range tests {
     576 + t.Run(tc.desc, func(t *testing.T) {
     577 + b, err := NewBuffer(tc.size)
     578 + if err != nil {
     579 + t.Fatalf("NewBuffer => unexpected error: %v", err)
     580 + }
     581 + got, err := b.RemWidth(tc.point)
     582 + if (err != nil) != tc.wantErr {
     583 + t.Errorf("RemWidth => unexpected error: %v, wantErr: %v", err, tc.wantErr)
     584 + }
     585 + if err != nil {
     586 + return
     587 + }
     588 + if got != tc.want {
     589 + t.Errorf("RemWidth => got %d, want %d", got, tc.want)
     590 + }
     591 + })
     592 + }
     593 +}
     594 + 
  • ■ ■ ■ ■ ■
    draw/border.go
    skipped 162 lines
    163 163   continue
    164 164   }
    165 165   
    166  - if err := c.SetCell(p, r, opt.cellOpts...); err != nil {
     166 + cells, err := c.SetCell(p, r, opt.cellOpts...)
     167 + if err != nil {
    167 168   return err
     169 + }
     170 + if cells != 1 {
     171 + panic(fmt.Sprintf("invalid border rune %q, this rune occupies %d cells, border implementation only supports half-width runes that occupy exactly one cell", r, cells))
    168 172   }
    169 173   }
    170 174   }
    skipped 7 lines
  • ■ ■ ■ ■ ■
    draw/rectangle.go
    skipped 77 lines
    78 78   
    79 79   for col := r.Min.X; col < r.Max.X; col++ {
    80 80   for row := r.Min.Y; row < r.Max.Y; row++ {
    81  - if err := c.SetCell(image.Point{col, row}, opt.char, opt.cellOpts...); err != nil {
     81 + cells, err := c.SetCell(image.Point{col, row}, opt.char, opt.cellOpts...)
     82 + if err != nil {
    82 83   return err
     84 + }
     85 + if cells != 1 {
     86 + return fmt.Errorf("invalid rectangle character %q, this character occupies %d cells, the implementation only supports half-width runes that occupy exactly one cell", opt.char, cells)
    83 87   }
    84 88   }
    85 89   }
    skipped 3 lines
  • ■ ■ ■ ■ ■ ■
    draw/rectangle_test.go
    skipped 49 lines
    50 50   },
    51 51   },
    52 52   {
     53 + desc: "fails when the rectangle character occupies multiple cells",
     54 + canvas: image.Rect(0, 0, 2, 2),
     55 + rect: image.Rect(0, 0, 1, 1),
     56 + opts: []RectangleOption{
     57 + RectChar('界'),
     58 + },
     59 + wantErr: true,
     60 + },
     61 + {
    53 62   desc: "sets cell options",
    54 63   canvas: image.Rect(0, 0, 2, 2),
    55 64   rect: image.Rect(0, 0, 1, 1),
    skipped 97 lines
  • ■ ■ ■ ■ ■ ■
    draw/text.go
    skipped 16 lines
    17 17  // text.go contains code that prints UTF-8 encoded strings on the canvas.
    18 18   
    19 19  import (
     20 + "bytes"
    20 21   "fmt"
    21 22   "image"
    22  - "unicode/utf8"
    23 23   
     24 + runewidth "github.com/mattn/go-runewidth"
    24 25   "github.com/mum4k/termdash/canvas"
    25 26   "github.com/mum4k/termdash/cell"
    26 27  )
    skipped 76 lines
    103 104   })
    104 105  }
    105 106   
     107 +// trimToCells trims the provided text so that it fits the specified amount of cells.
     108 +func trimToCells(text string, maxCells int, om OverrunMode) string {
     109 + if maxCells < 1 {
     110 + return ""
     111 + }
     112 + 
     113 + var b bytes.Buffer
     114 + cells := 0
     115 + for _, r := range text {
     116 + rw := runewidth.RuneWidth(r)
     117 + if cells+rw >= maxCells {
     118 + switch {
     119 + case om == OverrunModeTrim && rw == 1:
     120 + b.WriteRune(r)
     121 + case om == OverrunModeThreeDot:
     122 + b.WriteRune('…')
     123 + }
     124 + break
     125 + }
     126 + b.WriteRune(r)
     127 + cells += rw
     128 + }
     129 + return b.String()
     130 +}
     131 + 
    106 132  // bounds enforces the text bounds based on the specified overrun mode.
    107 133  // Returns test that can be safely drawn within the bounds.
    108  -func bounds(text string, maxRunes int, om OverrunMode) (string, error) {
    109  - runes := utf8.RuneCountInString(text)
    110  - if runes <= maxRunes {
     134 +func bounds(text string, maxCells int, om OverrunMode) (string, error) {
     135 + cells := runewidth.StringWidth(text)
     136 + if cells <= maxCells {
    111 137   return text, nil
    112 138   }
    113 139   
    114 140   switch om {
    115 141   case OverrunModeStrict:
    116  - return "", fmt.Errorf("the requested text %q takes %d runes to draw, space is available for only %d runes and overrun mode is %v", text, runes, maxRunes, om)
    117  - case OverrunModeTrim:
    118  - return text[:maxRunes], nil
    119  - 
    120  - case OverrunModeThreeDot:
    121  - return fmt.Sprintf("%s…", text[:maxRunes-1]), nil
     142 + return "", fmt.Errorf("the requested text %q takes %d cells to draw, space is available for only %d cells and overrun mode is %v", text, cells, maxCells, om)
     143 + case OverrunModeTrim, OverrunModeThreeDot:
     144 + return trimToCells(text, maxCells, om), nil
    122 145   default:
    123 146   return "", fmt.Errorf("unsupported overrun mode %v", om)
    124 147   }
    skipped 22 lines
    147 170   wantMaxX = opt.maxX
    148 171   }
    149 172   
    150  - maxRunes := wantMaxX - start.X
    151  - trimmed, err := bounds(text, maxRunes, opt.overrunMode)
     173 + maxCells := wantMaxX - start.X
     174 + trimmed, err := bounds(text, maxCells, opt.overrunMode)
    152 175   if err != nil {
    153 176   return err
    154 177   }
    155 178   
    156 179   cur := start
    157 180   for _, r := range trimmed {
    158  - if err := c.SetCell(cur, r, opt.cellOpts...); err != nil {
     181 + cells, err := c.SetCell(cur, r, opt.cellOpts...)
     182 + if err != nil {
    159 183   return err
    160 184   }
    161  - cur = image.Point{cur.X + 1, cur.Y}
     185 + cur = image.Point{cur.X + cells, cur.Y}
    162 186   }
    163 187   return nil
    164 188  }
    skipped 1 lines
  • ■ ■ ■ ■ ■ ■
    draw/text_test.go
    skipped 75 lines
    76 76   wantErr: true,
    77 77   },
    78 78   {
     79 + desc: "text falls outside of the canvas because the rune is full-width on OverrunModeStrict",
     80 + canvas: image.Rect(0, 0, 1, 1),
     81 + text: "界",
     82 + start: image.Point{0, 0},
     83 + want: func(size image.Point) *faketerm.Terminal {
     84 + return faketerm.MustNew(size)
     85 + },
     86 + wantErr: true,
     87 + },
     88 + {
    79 89   desc: "text falls outside of the canvas on OverrunModeTrim",
    80 90   canvas: image.Rect(0, 0, 1, 1),
    81 91   text: "ab",
    skipped 11 lines
    93 103   },
    94 104   },
    95 105   {
     106 + desc: "text falls outside of the canvas because the rune is full-width on OverrunModeTrim",
     107 + canvas: image.Rect(0, 0, 1, 1),
     108 + text: "界",
     109 + start: image.Point{0, 0},
     110 + opts: []TextOption{
     111 + TextOverrunMode(OverrunModeTrim),
     112 + },
     113 + want: func(size image.Point) *faketerm.Terminal {
     114 + ft := faketerm.MustNew(size)
     115 + c := testcanvas.MustNew(ft.Area())
     116 + testcanvas.MustApply(c, ft)
     117 + return ft
     118 + },
     119 + },
     120 + {
    96 121   desc: "OverrunModeTrim trims longer text",
    97 122   canvas: image.Rect(0, 0, 2, 1),
    98 123   text: "abcdef",
    skipped 12 lines
    111 136   },
    112 137   },
    113 138   {
     139 + desc: "OverrunModeTrim trims longer text with full-width runes, trim falls before the rune",
     140 + canvas: image.Rect(0, 0, 2, 1),
     141 + text: "ab界",
     142 + start: image.Point{0, 0},
     143 + opts: []TextOption{
     144 + TextOverrunMode(OverrunModeTrim),
     145 + },
     146 + want: func(size image.Point) *faketerm.Terminal {
     147 + ft := faketerm.MustNew(size)
     148 + c := testcanvas.MustNew(ft.Area())
     149 + 
     150 + testcanvas.MustSetCell(c, image.Point{0, 0}, 'a')
     151 + testcanvas.MustSetCell(c, image.Point{1, 0}, 'b')
     152 + testcanvas.MustApply(c, ft)
     153 + return ft
     154 + },
     155 + },
     156 + {
     157 + desc: "OverrunModeTrim trims longer text with full-width runes, trim falls on the rune",
     158 + canvas: image.Rect(0, 0, 2, 1),
     159 + text: "a界",
     160 + start: image.Point{0, 0},
     161 + opts: []TextOption{
     162 + TextOverrunMode(OverrunModeTrim),
     163 + },
     164 + want: func(size image.Point) *faketerm.Terminal {
     165 + ft := faketerm.MustNew(size)
     166 + c := testcanvas.MustNew(ft.Area())
     167 + 
     168 + testcanvas.MustSetCell(c, image.Point{0, 0}, 'a')
     169 + testcanvas.MustApply(c, ft)
     170 + return ft
     171 + },
     172 + },
     173 + {
    114 174   desc: "text falls outside of the canvas on OverrunModeThreeDot",
    115 175   canvas: image.Rect(0, 0, 1, 1),
    116 176   text: "ab",
    skipped 11 lines
    128 188   },
    129 189   },
    130 190   {
     191 + desc: "text falls outside of the canvas because the rune is full-width on OverrunModeThreeDot",
     192 + canvas: image.Rect(0, 0, 1, 1),
     193 + text: "界",
     194 + start: image.Point{0, 0},
     195 + opts: []TextOption{
     196 + TextOverrunMode(OverrunModeThreeDot),
     197 + },
     198 + want: func(size image.Point) *faketerm.Terminal {
     199 + ft := faketerm.MustNew(size)
     200 + c := testcanvas.MustNew(ft.Area())
     201 + 
     202 + testcanvas.MustSetCell(c, image.Point{0, 0}, '…')
     203 + testcanvas.MustApply(c, ft)
     204 + return ft
     205 + },
     206 + },
     207 + {
    131 208   desc: "OverrunModeThreeDot trims longer text",
    132 209   canvas: image.Rect(0, 0, 2, 1),
    133 210   text: "abcdef",
     211 + start: image.Point{0, 0},
     212 + opts: []TextOption{
     213 + TextOverrunMode(OverrunModeThreeDot),
     214 + },
     215 + want: func(size image.Point) *faketerm.Terminal {
     216 + ft := faketerm.MustNew(size)
     217 + c := testcanvas.MustNew(ft.Area())
     218 + 
     219 + testcanvas.MustSetCell(c, image.Point{0, 0}, 'a')
     220 + testcanvas.MustSetCell(c, image.Point{1, 0}, '…')
     221 + testcanvas.MustApply(c, ft)
     222 + return ft
     223 + },
     224 + },
     225 + {
     226 + desc: "OverrunModeThreeDot trims longer text with full-width runes, trim falls before the rune",
     227 + canvas: image.Rect(0, 0, 2, 1),
     228 + text: "ab界",
     229 + start: image.Point{0, 0},
     230 + opts: []TextOption{
     231 + TextOverrunMode(OverrunModeThreeDot),
     232 + },
     233 + want: func(size image.Point) *faketerm.Terminal {
     234 + ft := faketerm.MustNew(size)
     235 + c := testcanvas.MustNew(ft.Area())
     236 + 
     237 + testcanvas.MustSetCell(c, image.Point{0, 0}, 'a')
     238 + testcanvas.MustSetCell(c, image.Point{1, 0}, '…')
     239 + testcanvas.MustApply(c, ft)
     240 + return ft
     241 + },
     242 + },
     243 + {
     244 + desc: "OverrunModeThreeDot trims longer text with full-width runes, trim falls on the rune",
     245 + canvas: image.Rect(0, 0, 2, 1),
     246 + text: "a界",
    134 247   start: image.Point{0, 0},
    135 248   opts: []TextOption{
    136 249   TextOverrunMode(OverrunModeThreeDot),
    skipped 90 lines
    227 340   },
    228 341   },
    229 342   {
    230  - desc: "draws unicode character",
     343 + desc: "draws a half-width unicode character",
    231 344   canvas: image.Rect(0, 0, 1, 1),
    232 345   text: "⇄",
    233 346   start: image.Point{0, 0},
    skipped 7 lines
    241 354   },
    242 355   },
    243 356   {
    244  - desc: "draws multiple unicode characters",
     357 + desc: "draws multiple half-width unicode characters",
    245 358   canvas: image.Rect(0, 0, 3, 3),
    246 359   text: "⇄࿃°",
    247 360   start: image.Point{0, 0},
    skipped 4 lines
    252 365   testcanvas.MustSetCell(c, image.Point{0, 0}, '⇄')
    253 366   testcanvas.MustSetCell(c, image.Point{1, 0}, '࿃')
    254 367   testcanvas.MustSetCell(c, image.Point{2, 0}, '°')
     368 + testcanvas.MustApply(c, ft)
     369 + return ft
     370 + },
     371 + },
     372 + {
     373 + desc: "draws multiple full-width unicode characters",
     374 + canvas: image.Rect(0, 0, 10, 3),
     375 + text: "你好,世界",
     376 + start: image.Point{0, 0},
     377 + want: func(size image.Point) *faketerm.Terminal {
     378 + ft := faketerm.MustNew(size)
     379 + c := testcanvas.MustNew(ft.Area())
     380 + 
     381 + testcanvas.MustSetCell(c, image.Point{0, 0}, '你')
     382 + testcanvas.MustSetCell(c, image.Point{2, 0}, '好')
     383 + testcanvas.MustSetCell(c, image.Point{4, 0}, ',')
     384 + testcanvas.MustSetCell(c, image.Point{6, 0}, '世')
     385 + testcanvas.MustSetCell(c, image.Point{8, 0}, '界')
    255 386   testcanvas.MustApply(c, ft)
    256 387   return ft
    257 388   },
    skipped 34 lines
  • images/gaugedemo.gif
  • images/textdemo.gif
  • ■ ■ ■ ■ ■
    terminal/faketerm/faketerm.go
    skipped 22 lines
    23 23   "log"
    24 24   "sync"
    25 25   
    26  - "github.com/mum4k/termdash/area"
    27 26   "github.com/mum4k/termdash/cell"
    28 27   "github.com/mum4k/termdash/eventqueue"
    29 28   "github.com/mum4k/termdash/terminalapi"
    skipped 149 lines
    179 178   t.mu.Lock()
    180 179   defer t.mu.Unlock()
    181 180   
    182  - ar, err := area.FromSize(t.buffer.Size())
    183  - if err != nil {
     181 + if _, err := t.buffer.SetCell(p, r, opts...); err != nil {
    184 182   return err
    185 183   }
    186  - if !p.In(ar) {
    187  - return fmt.Errorf("cell at point %+v falls out of the terminal area %+v", p, ar)
    188  - }
    189  - 
    190  - cell := t.buffer[p.X][p.Y]
    191  - cell.Rune = r
    192  - cell.Apply(opts...)
    193 184   return nil
    194 185  }
    195 186   
    skipped 20 lines
  • ■ ■ ■ ■ ■ ■
    widgets/text/line_scanner.go
    skipped 18 lines
    19 19  import (
    20 20   "strings"
    21 21   "text/scanner"
     22 + 
     23 + runewidth "github.com/mattn/go-runewidth"
    22 24  )
    23 25   
    24 26  // wrapNeeded returns true if wrapping is needed for the rune at the horizontal
    skipped 4 lines
    29 31   // canvas, i.e. they take no horizontal space.
    30 32   return false
    31 33   }
    32  - return cvsPosX >= cvsWidth && opts.wrapAtRunes
     34 + rw := runewidth.RuneWidth(r)
     35 + return cvsPosX > cvsWidth-rw && opts.wrapAtRunes
    33 36  }
    34 37   
    35 38  // findLines finds the starting positions of all lines in the text when the
    skipped 67 lines
    103 106  // scanLine scans a line until it finds its end.
    104 107  func scanLine(ls *lineScanner) scannerState {
    105 108   for {
    106  - tok := ls.scanner.Scan()
    107  - //switch tok := ls.scanner.Scan(); {
    108  - switch {
     109 + switch tok := ls.scanner.Scan(); {
    109 110   case tok == scanner.EOF:
    110 111   return nil
    111 112   
    skipped 5 lines
    117 118   
    118 119   default:
    119 120   // Move horizontally within the line for each scanned character.
    120  - ls.cvsPosX++
     121 + ls.cvsPosX += runewidth.RuneWidth(tok)
    121 122   }
    122 123   }
    123 124  }
    skipped 12 lines
    136 137  func scanLineWrap(ls *lineScanner) scannerState {
    137 138   // The character on which we wrapped will be printed and is the start of
    138 139   // new line.
    139  - ls.cvsPosX = 1
     140 + ls.cvsPosX = runewidth.StringWidth(ls.scanner.TokenText())
    140 141   ls.lines = append(ls.lines, ls.scanner.Position.Offset)
    141 142   return scanLine
    142 143  }
    skipped 1 lines
  • ■ ■ ■ ■ ■
    widgets/text/line_scanner_test.go
    skipped 30 lines
    31 31   want bool
    32 32   }{
    33 33   {
    34  - desc: "point within canvas",
     34 + desc: "half-width rune, falls within canvas",
    35 35   r: 'a',
    36 36   point: image.Point{2, 0},
    37 37   cvsWidth: 3,
    skipped 1 lines
    39 39   want: false,
    40 40   },
    41 41   {
    42  - desc: "point outside of canvas, wrapping not configured",
     42 + desc: "full-width rune, falls within canvas",
     43 + r: '世',
     44 + point: image.Point{1, 0},
     45 + cvsWidth: 3,
     46 + opts: &options{},
     47 + want: false,
     48 + },
     49 + {
     50 + desc: "half-width rune, falls outside of canvas, wrapping not configured",
    43 51   r: 'a',
    44 52   point: image.Point{3, 0},
    45 53   cvsWidth: 3,
    skipped 1 lines
    47 55   want: false,
    48 56   },
    49 57   {
    50  - desc: "point outside of canvas, wrapping configured",
     58 + desc: "full-width rune, starts outside of canvas, wrapping not configured",
     59 + r: '世',
     60 + point: image.Point{3, 0},
     61 + cvsWidth: 3,
     62 + opts: &options{},
     63 + want: false,
     64 + },
     65 + {
     66 + desc: "half-width rune, falls outside of canvas, wrapping configured",
    51 67   r: 'a',
     68 + point: image.Point{3, 0},
     69 + cvsWidth: 3,
     70 + opts: &options{
     71 + wrapAtRunes: true,
     72 + },
     73 + want: true,
     74 + },
     75 + {
     76 + desc: "full-width rune, starts in and falls outside of canvas, wrapping configured",
     77 + r: '世',
     78 + point: image.Point{2, 0},
     79 + cvsWidth: 3,
     80 + opts: &options{
     81 + wrapAtRunes: true,
     82 + },
     83 + want: true,
     84 + },
     85 + {
     86 + desc: "full-width rune, starts outside of canvas, wrapping configured",
     87 + r: '世',
    52 88   point: image.Point{3, 0},
    53 89   cvsWidth: 3,
    54 90   opts: &options{
    skipped 120 lines
    175 211   want: []int{0, 4, 8},
    176 212   },
    177 213   {
    178  - desc: "wrapping enabled, newlines, doesn't fit in canvas width, wide unicode characters",
     214 + desc: "wrapping enabled, newlines, doesn't fit in width, full-width unicode characters",
    179 215   text: "你好\n世界",
    180  - cvsWidth: 1,
     216 + cvsWidth: 2,
    181 217   opts: &options{
    182 218   wrapAtRunes: true,
    183 219   },
    184 220   want: []int{0, 3, 7, 10},
    185 221   },
    186  - 
     222 + {
     223 + desc: "wraps before a full-width character that starts in and falls out",
     224 + text: "a你b",
     225 + cvsWidth: 2,
     226 + opts: &options{
     227 + wrapAtRunes: true,
     228 + },
     229 + want: []int{0, 1, 4},
     230 + },
     231 + {
     232 + desc: "wraps before a full-width character that falls out",
     233 + text: "ab你b",
     234 + cvsWidth: 2,
     235 + opts: &options{
     236 + wrapAtRunes: true,
     237 + },
     238 + want: []int{0, 2, 5},
     239 + },
    187 240   {
    188 241   desc: "handles leading and trailing newlines",
    189 242   text: "\n\n\nhello\n\n\n",
    skipped 46 lines
  • ■ ■ ■ ■ ■ ■
    widgets/text/line_trim.go
     1 +package text
     2 + 
     3 +import (
     4 + "fmt"
     5 + "image"
     6 + 
     7 + runewidth "github.com/mattn/go-runewidth"
     8 + "github.com/mum4k/termdash/canvas"
     9 +)
     10 + 
     11 +// line_trim.go contains code that trims lines that are too long.
     12 + 
     13 +type trimResult struct {
     14 + // trimmed is set to true if the current and the following runes on this
     15 + // line are trimmed.
     16 + trimmed bool
     17 + 
     18 + // curPoint is the updated current point the drawing should continue on.
     19 + curPoint image.Point
     20 +}
     21 + 
     22 +// drawTrimChar draws the horizontal ellipsis '…' character as the last
     23 +// character in the canvas on the specified line.
     24 +func drawTrimChar(cvs *canvas.Canvas, line int) error {
     25 + lastPoint := image.Point{cvs.Area().Dx() - 1, line}
     26 + // If the penultimate cell contains a full-width rune, we need to clear it
     27 + // first. Otherwise the trim char would cover just half of it.
     28 + if width := cvs.Area().Dx(); width > 1 {
     29 + penUlt := image.Point{width - 2, line}
     30 + prev, err := cvs.Cell(penUlt)
     31 + if err != nil {
     32 + return err
     33 + }
     34 + 
     35 + if runewidth.RuneWidth(prev.Rune) == 2 {
     36 + if _, err := cvs.SetCell(penUlt, 0); err != nil {
     37 + return err
     38 + }
     39 + }
     40 + }
     41 + 
     42 + cells, err := cvs.SetCell(lastPoint, '…')
     43 + if err != nil {
     44 + return err
     45 + }
     46 + if cells != 1 {
     47 + panic(fmt.Errorf("invalid trim character, it occupies %d cells, the implementation only supports scroll markers that occupy exactly one cell", cells))
     48 + }
     49 + return nil
     50 +}
     51 + 
     52 +// lineTrim determines if the current line needs to be trimmed. The cvs is the
     53 +// canvas assigned to the widget, the curPoint is the current point the widget
     54 +// is going to place the curRune at. If line trimming is needed, this function
     55 +// replaces the last character with the horizontal ellipsis '…' character.
     56 +func lineTrim(cvs *canvas.Canvas, curPoint image.Point, curRune rune, opts *options) (*trimResult, error) {
     57 + if opts.wrapAtRunes {
     58 + // Don't trim if the widget is configured to wrap lines.
     59 + return &trimResult{
     60 + trimmed: false,
     61 + curPoint: curPoint,
     62 + }, nil
     63 + }
     64 + 
     65 + // Newline characters are never trimmed, they start the next line.
     66 + if curRune == '\n' {
     67 + return &trimResult{
     68 + trimmed: false,
     69 + curPoint: curPoint,
     70 + }, nil
     71 + }
     72 + 
     73 + width := cvs.Area().Dx()
     74 + rw := runewidth.RuneWidth(curRune)
     75 + switch {
     76 + case rw == 1:
     77 + if curPoint.X == width {
     78 + if err := drawTrimChar(cvs, curPoint.Y); err != nil {
     79 + return nil, err
     80 + }
     81 + }
     82 + 
     83 + case rw == 2:
     84 + if curPoint.X == width || curPoint.X == width-1 {
     85 + if err := drawTrimChar(cvs, curPoint.Y); err != nil {
     86 + return nil, err
     87 + }
     88 + }
     89 + 
     90 + default:
     91 + return nil, fmt.Errorf("unable to decide line trimming at position %v for rune %q which has an unsupported width %d", curPoint, curRune, rw)
     92 + }
     93 + 
     94 + trimmed := curPoint.X > width-rw
     95 + if trimmed {
     96 + curPoint = image.Point{curPoint.X + rw, curPoint.Y}
     97 + }
     98 + return &trimResult{
     99 + trimmed: trimmed,
     100 + curPoint: curPoint,
     101 + }, nil
     102 +}
     103 + 
  • ■ ■ ■ ■ ■ ■
    widgets/text/line_trim_test.go
     1 +package text
     2 + 
     3 +import (
     4 + "image"
     5 + "testing"
     6 + 
     7 + "github.com/kylelemons/godebug/pretty"
     8 + "github.com/mum4k/termdash/canvas"
     9 + "github.com/mum4k/termdash/canvas/testcanvas"
     10 + "github.com/mum4k/termdash/draw/testdraw"
     11 + "github.com/mum4k/termdash/terminal/faketerm"
     12 +)
     13 + 
     14 +func TestLineTrim(t *testing.T) {
     15 + cvsArea := image.Rect(0, 0, 10, 1)
     16 + tests := []struct {
     17 + desc string
     18 + cvs *canvas.Canvas
     19 + curPoint image.Point
     20 + curRune rune
     21 + opts *options
     22 + wantRes *trimResult
     23 + want func(size image.Point) *faketerm.Terminal
     24 + wantErr bool
     25 + }{
     26 + {
     27 + desc: "half-width rune, beginning of the canvas",
     28 + cvs: testcanvas.MustNew(cvsArea),
     29 + curPoint: image.Point{0, 0},
     30 + curRune: 'A',
     31 + opts: &options{
     32 + wrapAtRunes: false,
     33 + },
     34 + wantRes: &trimResult{
     35 + trimmed: false,
     36 + curPoint: image.Point{0, 0},
     37 + },
     38 + want: func(size image.Point) *faketerm.Terminal {
     39 + return faketerm.MustNew(size)
     40 + },
     41 + },
     42 + {
     43 + desc: "half-width rune, end of the canvas, fits",
     44 + cvs: testcanvas.MustNew(cvsArea),
     45 + curPoint: image.Point{9, 0},
     46 + curRune: 'A',
     47 + opts: &options{
     48 + wrapAtRunes: false,
     49 + },
     50 + wantRes: &trimResult{
     51 + trimmed: false,
     52 + curPoint: image.Point{9, 0},
     53 + },
     54 + want: func(size image.Point) *faketerm.Terminal {
     55 + return faketerm.MustNew(size)
     56 + },
     57 + },
     58 + {
     59 + desc: "full-width rune, end of the canvas, fits",
     60 + cvs: testcanvas.MustNew(cvsArea),
     61 + curPoint: image.Point{8, 0},
     62 + curRune: '世',
     63 + opts: &options{
     64 + wrapAtRunes: false,
     65 + },
     66 + wantRes: &trimResult{
     67 + trimmed: false,
     68 + curPoint: image.Point{8, 0},
     69 + },
     70 + want: func(size image.Point) *faketerm.Terminal {
     71 + return faketerm.MustNew(size)
     72 + },
     73 + },
     74 + {
     75 + desc: "half-width rune, falls out of the canvas, not configured to trim",
     76 + cvs: testcanvas.MustNew(cvsArea),
     77 + curPoint: image.Point{10, 0},
     78 + curRune: 'A',
     79 + opts: &options{
     80 + wrapAtRunes: true,
     81 + },
     82 + wantRes: &trimResult{
     83 + trimmed: false,
     84 + curPoint: image.Point{10, 0},
     85 + },
     86 + want: func(size image.Point) *faketerm.Terminal {
     87 + return faketerm.MustNew(size)
     88 + },
     89 + },
     90 + {
     91 + desc: "half-width rune, first that falls out of the canvas, trimmed and marked",
     92 + cvs: testcanvas.MustNew(cvsArea),
     93 + curPoint: image.Point{10, 0},
     94 + curRune: 'A',
     95 + opts: &options{
     96 + wrapAtRunes: false,
     97 + },
     98 + wantRes: &trimResult{
     99 + trimmed: true,
     100 + curPoint: image.Point{11, 0},
     101 + },
     102 + want: func(size image.Point) *faketerm.Terminal {
     103 + ft := faketerm.MustNew(size)
     104 + c := testcanvas.MustNew(ft.Area())
     105 + testdraw.MustText(c, "…", image.Point{9, 0})
     106 + testcanvas.MustApply(c, ft)
     107 + return ft
     108 + },
     109 + },
     110 + {
     111 + desc: "full-width rune, starts in and falls out, trimmed and marked",
     112 + cvs: testcanvas.MustNew(cvsArea),
     113 + curPoint: image.Point{9, 0},
     114 + curRune: '世',
     115 + opts: &options{
     116 + wrapAtRunes: false,
     117 + },
     118 + wantRes: &trimResult{
     119 + trimmed: true,
     120 + curPoint: image.Point{11, 0},
     121 + },
     122 + want: func(size image.Point) *faketerm.Terminal {
     123 + ft := faketerm.MustNew(size)
     124 + c := testcanvas.MustNew(ft.Area())
     125 + testdraw.MustText(c, "…", image.Point{9, 0})
     126 + testcanvas.MustApply(c, ft)
     127 + return ft
     128 + },
     129 + },
     130 + {
     131 + desc: "full-width rune, starts out, trimmed and marked",
     132 + cvs: testcanvas.MustNew(cvsArea),
     133 + curPoint: image.Point{10, 0},
     134 + curRune: '世',
     135 + opts: &options{
     136 + wrapAtRunes: false,
     137 + },
     138 + wantRes: &trimResult{
     139 + trimmed: true,
     140 + curPoint: image.Point{12, 0},
     141 + },
     142 + want: func(size image.Point) *faketerm.Terminal {
     143 + ft := faketerm.MustNew(size)
     144 + c := testcanvas.MustNew(ft.Area())
     145 + testdraw.MustText(c, "…", image.Point{9, 0})
     146 + testcanvas.MustApply(c, ft)
     147 + return ft
     148 + },
     149 + },
     150 + {
     151 + desc: "newline rune, first that falls out of the canvas, not trimmed or marked",
     152 + cvs: testcanvas.MustNew(cvsArea),
     153 + curPoint: image.Point{10, 0},
     154 + curRune: '\n',
     155 + opts: &options{
     156 + wrapAtRunes: false,
     157 + },
     158 + wantRes: &trimResult{
     159 + trimmed: false,
     160 + curPoint: image.Point{10, 0},
     161 + },
     162 + want: func(size image.Point) *faketerm.Terminal {
     163 + return faketerm.MustNew(size)
     164 + },
     165 + },
     166 + {
     167 + desc: "half-width rune, n-th that falls out of the canvas, trimmed and not marked",
     168 + cvs: testcanvas.MustNew(cvsArea),
     169 + curPoint: image.Point{11, 0},
     170 + curRune: 'A',
     171 + opts: &options{
     172 + wrapAtRunes: false,
     173 + },
     174 + wantRes: &trimResult{
     175 + trimmed: true,
     176 + curPoint: image.Point{12, 0},
     177 + },
     178 + want: func(size image.Point) *faketerm.Terminal {
     179 + return faketerm.MustNew(size)
     180 + },
     181 + },
     182 + {
     183 + desc: "full-width rune, n-th that falls out of the canvas, trimmed and not marked",
     184 + cvs: testcanvas.MustNew(cvsArea),
     185 + curPoint: image.Point{11, 0},
     186 + curRune: '世',
     187 + opts: &options{
     188 + wrapAtRunes: false,
     189 + },
     190 + wantRes: &trimResult{
     191 + trimmed: true,
     192 + curPoint: image.Point{13, 0},
     193 + },
     194 + want: func(size image.Point) *faketerm.Terminal {
     195 + return faketerm.MustNew(size)
     196 + },
     197 + },
     198 + {
     199 + desc: "newline rune, n-th that falls out of the canvas, not trimmed or marked",
     200 + cvs: testcanvas.MustNew(cvsArea),
     201 + curPoint: image.Point{11, 0},
     202 + curRune: '\n',
     203 + opts: &options{
     204 + wrapAtRunes: false,
     205 + },
     206 + wantRes: &trimResult{
     207 + trimmed: false,
     208 + curPoint: image.Point{11, 0},
     209 + },
     210 + want: func(size image.Point) *faketerm.Terminal {
     211 + return faketerm.MustNew(size)
     212 + },
     213 + },
     214 + {
     215 + desc: "full-width rune, starts out, previous is also full, trimmed and marked",
     216 + cvs: func() *canvas.Canvas {
     217 + cvs := testcanvas.MustNew(cvsArea)
     218 + testcanvas.MustSetCell(cvs, image.Point{8, 0}, '世')
     219 + return cvs
     220 + }(),
     221 + curPoint: image.Point{10, 0},
     222 + curRune: '世',
     223 + opts: &options{
     224 + wrapAtRunes: false,
     225 + },
     226 + wantRes: &trimResult{
     227 + trimmed: true,
     228 + curPoint: image.Point{12, 0},
     229 + },
     230 + want: func(size image.Point) *faketerm.Terminal {
     231 + ft := faketerm.MustNew(size)
     232 + c := testcanvas.MustNew(ft.Area())
     233 + testdraw.MustText(c, "…", image.Point{9, 0})
     234 + testcanvas.MustApply(c, ft)
     235 + return ft
     236 + },
     237 + },
     238 + }
     239 + 
     240 + for _, tc := range tests {
     241 + t.Run(tc.desc, func(t *testing.T) {
     242 + gotRes, err := lineTrim(tc.cvs, tc.curPoint, tc.curRune, tc.opts)
     243 + if (err != nil) != tc.wantErr {
     244 + t.Errorf("lineTrim => unexpected error: %v, wantErr: %v", err, tc.wantErr)
     245 + }
     246 + if err != nil {
     247 + return
     248 + }
     249 + 
     250 + if diff := pretty.Compare(tc.wantRes, gotRes); diff != "" {
     251 + t.Errorf("lineTrim => unexpected result, diff (-want, +got):\n%s", diff)
     252 + }
     253 + 
     254 + got, err := faketerm.New(tc.cvs.Size())
     255 + if err != nil {
     256 + t.Fatalf("faketerm.New => unexpected error: %v", err)
     257 + }
     258 + 
     259 + if err := tc.cvs.Apply(got); err != nil {
     260 + t.Fatalf("Apply => unexpected error: %v", err)
     261 + }
     262 + 
     263 + if diff := faketerm.Diff(tc.want(tc.cvs.Size()), got); diff != "" {
     264 + t.Errorf("lineTrim => %v", diff)
     265 + }
     266 + })
     267 + }
     268 +}
     269 + 
  • ■ ■ ■ ■ ■ ■
    widgets/text/text.go
    skipped 110 lines
    111 111   return nil
    112 112  }
    113 113   
    114  -// lineTrimNeeded returns true if the text on this line needs to be trimmed.
    115  -// I.e. if the Text gadget was configured to trim lines, the current point falls
    116  -// outside of the canvas and the current rune doesn't start a new line.
    117  -func (t *Text) lineTrimNeeded(cur image.Point, cvs *canvas.Canvas, r rune) bool {
    118  - if cur.X < cvs.Area().Dx() || r == '\n' {
    119  - return false
    120  - }
    121  - return !t.opts.wrapAtRunes
    122  -}
    123  - 
    124 114  // minLinesForMarkers are the minimum amount of lines required on the canvas in
    125 115  // order to draw the scroll markers ('⇧' and '⇩').
    126 116  const minLinesForMarkers = 3
    127 117   
     118 +// drawScrollUp draws the scroll up marker on the first line if there is more
     119 +// text "above" the canvas due to the scrolling position. Returns true if the
     120 +// marker was drawn.
     121 +func (t *Text) drawScrollUp(cvs *canvas.Canvas, cur image.Point, fromLine int) (bool, error) {
     122 + height := cvs.Area().Dy()
     123 + if cur.Y == 0 && height >= minLinesForMarkers && fromLine > 0 {
     124 + cells, err := cvs.SetCell(cur, '⇧')
     125 + if err != nil {
     126 + return false, err
     127 + }
     128 + if cells != 1 {
     129 + panic(fmt.Errorf("invalid scroll up marker, it occupies %d cells, the implementation only supports scroll markers that occupy exactly one cell", cells))
     130 + }
     131 + return true, nil
     132 + }
     133 + return false, nil
     134 +}
     135 + 
     136 +// drawScrollDown draws the scroll down marker on the last line if there is
     137 +// more text "below" the canvas due to the scrolling position. Returns true if
     138 +// the marker was drawn.
     139 +func (t *Text) drawScrollDown(cvs *canvas.Canvas, cur image.Point, fromLine int) (bool, error) {
     140 + height := cvs.Area().Dy()
     141 + lines := len(t.lines)
     142 + if cur.Y == height-1 && height >= minLinesForMarkers && height < lines-fromLine {
     143 + cells, err := cvs.SetCell(cur, '⇩')
     144 + if err != nil {
     145 + return false, err
     146 + }
     147 + if cells != 1 {
     148 + panic(fmt.Errorf("invalid scroll down marker, it occupies %d cells, the implementation only supports scroll markers that occupy exactly one cell", cells))
     149 + }
     150 + return true, nil
     151 + }
     152 + return false, nil
     153 +}
     154 + 
    128 155  // draw draws the text context on the canvas starting at the specified line.
    129  -// Argument starts are the starting positions of all the lines in the text.
    130  -func (t *Text) draw(text string, cvs *canvas.Canvas, starts []int, fromLine int) error {
     156 +func (t *Text) draw(text string, cvs *canvas.Canvas) error {
    131 157   var cur image.Point // Tracks the current drawing position on the canvas.
    132  - lines := len(starts)
    133 158   height := cvs.Area().Dy()
     159 + fromLine := t.scroll.firstLine(len(t.lines), height)
    134 160   optRange := t.givenWOpts.forPosition(0) // Text options for the current byte.
    135  - startPos := starts[fromLine]
     161 + startPos := t.lines[fromLine]
    136 162   for i, r := range text {
    137 163   if i < startPos {
    138 164   continue
    139 165   }
    140 166   
    141  - // Draw the scroll up marker on the first line if there is more text
    142  - // above the canvas.
    143  - if cur.Y == 0 && height >= minLinesForMarkers && fromLine > 0 {
    144  - if err := cvs.SetCell(cur, '⇧'); err != nil {
    145  - return err
    146  - }
     167 + // Scroll up marker.
     168 + scrlUp, err := t.drawScrollUp(cvs, cur, fromLine)
     169 + if err != nil {
     170 + return err
     171 + }
     172 + if scrlUp {
    147 173   cur = image.Point{0, cur.Y + 1} // Move to the next line.
    148  - startPos = starts[fromLine+1] // Skip one line of text, the marker replaced it.
     174 + startPos = t.lines[fromLine+1] // Skip one line of text, the marker replaced it.
    149 175   continue
    150 176   }
    151 177   
     178 + // Line wrapping.
    152 179   if r == '\n' || wrapNeeded(r, cur.X, cvs.Area().Dx(), t.opts) {
    153 180   cur = image.Point{0, cur.Y + 1} // Move to the next line.
    154 181   }
    155 182   
    156  - // Draw the scroll down marker on the last line if there is more text
    157  - // below the canvas.
    158  - if cur.Y == height-1 && height >= minLinesForMarkers && height < lines-fromLine {
    159  - if height >= minLinesForMarkers {
    160  - if err := cvs.SetCell(cur, '⇩'); err != nil {
    161  - return err
    162  - }
    163  - }
    164  - break
     183 + // Scroll down marker.
     184 + scrlDown, err := t.drawScrollDown(cvs, cur, fromLine)
     185 + if err != nil {
     186 + return err
    165 187   }
    166  - 
    167  - if r == '\n' {
    168  - continue // Don't print the newline runes, just interpret them above.
     188 + if scrlDown || cur.Y >= height {
     189 + break // Trim all lines falling after the canvas.
    169 190   }
    170 191   
    171  - if t.lineTrimNeeded(cur, cvs, r) {
    172  - // Trim by replacing the last printed rune.
    173  - prev := image.Point{cur.X - 1, cur.Y}
    174  - if prev.In(cvs.Area()) {
    175  - if err := cvs.SetCell(prev, '…'); err != nil {
    176  - return err
    177  - }
    178  - }
     192 + tr, err := lineTrim(cvs, cur, r, t.opts)
     193 + if err != nil {
     194 + return err
     195 + }
     196 + cur = tr.curPoint
     197 + if tr.trimmed {
     198 + continue // Skip over any characters trimmed on the current line.
    179 199   }
    180 200   
    181  - if !cur.In(cvs.Area()) {
    182  - continue // Skip any runes belonging to the trimmed area on a line.
     201 + if r == '\n' {
     202 + continue // Don't print the newline runes, just interpret them above.
    183 203   }
    184 204   
    185 205   if i >= optRange.high { // Get the next write options.
    186 206   optRange = t.givenWOpts.forPosition(i)
    187 207   }
    188  - if err := cvs.SetCell(cur, r, optRange.opts.cellOpts); err != nil {
     208 + cells, err := cvs.SetCell(cur, r, optRange.opts.cellOpts)
     209 + if err != nil {
    189 210   return err
    190 211   }
    191  - cur = image.Point{cur.X + 1, cur.Y} // Move within the same line.
     212 + cur = image.Point{cur.X + cells, cur.Y} // Move within the same line.
    192 213   }
    193 214   return nil
    194 215  }
    skipped 17 lines
    212 233   return nil // Nothing to draw if there's no text.
    213 234   }
    214 235   
    215  - height := cvs.Area().Dy()
    216  - fromLine := t.scroll.firstLine(len(t.lines), height)
    217  - if err := t.draw(text, cvs, t.lines, fromLine); err != nil {
     236 + if err := t.draw(text, cvs); err != nil {
    218 237   return err
    219 238   }
    220 239   t.contentChanged = false
    skipped 34 lines
    255 274   
    256 275  func (t *Text) Options() widgetapi.Options {
    257 276   return widgetapi.Options{
     277 + // At least one line with at least one full-width rune.
    258 278   MinimumSize: image.Point{1, 1},
    259 279   WantMouse: !t.opts.disableScrolling,
    260 280   WantKeyboard: !t.opts.disableScrolling,
    skipped 23 lines
  • ■ ■ ■ ■ ■ ■
    widgets/text/text_test.go
    skipped 74 lines
    75 75   },
    76 76   },
    77 77   {
     78 + desc: "draws line of full-width runes",
     79 + canvas: image.Rect(0, 0, 10, 1),
     80 + writes: func(widget *Text) error {
     81 + return widget.Write("你好,世界")
     82 + },
     83 + want: func(size image.Point) *faketerm.Terminal {
     84 + ft := faketerm.MustNew(size)
     85 + c := testcanvas.MustNew(ft.Area())
     86 + 
     87 + testdraw.MustText(c, "你好,世界", image.Point{0, 0})
     88 + testcanvas.MustApply(c, ft)
     89 + return ft
     90 + },
     91 + },
     92 + {
    78 93   desc: "multiple writes append",
    79 94   canvas: image.Rect(0, 0, 12, 1),
    80 95   writes: func(widget *Text) error {
    skipped 86 lines
    167 182   },
    168 183   {
    169 184   desc: "trims long lines",
    170  - canvas: image.Rect(0, 0, 10, 3),
     185 + canvas: image.Rect(0, 0, 10, 4),
    171 186   writes: func(widget *Text) error {
    172  - return widget.Write("hello world\nshort\nand long again")
     187 + return widget.Write("hello world\nshort\nexactly 10\nand long again")
    173 188   },
    174 189   want: func(size image.Point) *faketerm.Terminal {
    175 190   ft := faketerm.MustNew(size)
    skipped 1 lines
    177 192   
    178 193   testdraw.MustText(c, "hello wor…", image.Point{0, 0})
    179 194   testdraw.MustText(c, "short", image.Point{0, 1})
     195 + testdraw.MustText(c, "exactly 10", image.Point{0, 2})
     196 + testdraw.MustText(c, "and long …", image.Point{0, 3})
     197 + testcanvas.MustApply(c, ft)
     198 + return ft
     199 + },
     200 + },
     201 + {
     202 + desc: "trims long lines with full-width runes",
     203 + canvas: image.Rect(0, 0, 10, 3),
     204 + writes: func(widget *Text) error {
     205 + return widget.Write("hello wor你\nhello wor你d\nand long 世")
     206 + },
     207 + want: func(size image.Point) *faketerm.Terminal {
     208 + ft := faketerm.MustNew(size)
     209 + c := testcanvas.MustNew(ft.Area())
     210 + 
     211 + testdraw.MustText(c, "hello wor…", image.Point{0, 0})
     212 + testdraw.MustText(c, "hello wor…", image.Point{0, 1})
    180 213   testdraw.MustText(c, "and long …", image.Point{0, 2})
    181 214   testcanvas.MustApply(c, ft)
    182 215   return ft
    skipped 195 lines
    378 411   },
    379 412   },
    380 413   {
    381  - desc: "wraps lines at rune boundaries",
     414 + desc: "wraps lines at half-width rune boundaries",
    382 415   canvas: image.Rect(0, 0, 10, 5),
    383 416   opts: []Option{
    384 417   WrapAtRunes(),
    skipped 10 lines
    395 428   testdraw.MustText(c, "short", image.Point{0, 2})
    396 429   testdraw.MustText(c, "and long a", image.Point{0, 3})
    397 430   testdraw.MustText(c, "gain", image.Point{0, 4})
     431 + testcanvas.MustApply(c, ft)
     432 + return ft
     433 + },
     434 + },
     435 + {
     436 + desc: "wraps lines at full-width rune boundaries",
     437 + canvas: image.Rect(0, 0, 10, 6),
     438 + opts: []Option{
     439 + WrapAtRunes(),
     440 + },
     441 + writes: func(widget *Text) error {
     442 + return widget.Write("hello wor你\nhello wor你d\nand long 世")
     443 + },
     444 + want: func(size image.Point) *faketerm.Terminal {
     445 + ft := faketerm.MustNew(size)
     446 + c := testcanvas.MustNew(ft.Area())
     447 + 
     448 + testdraw.MustText(c, "hello wor", image.Point{0, 0})
     449 + testdraw.MustText(c, "你", image.Point{0, 1})
     450 + testdraw.MustText(c, "hello wor", image.Point{0, 2})
     451 + testdraw.MustText(c, "你d", image.Point{0, 3})
     452 + testdraw.MustText(c, "and long ", image.Point{0, 4})
     453 + testdraw.MustText(c, "世", image.Point{0, 5})
    398 454   testcanvas.MustApply(c, ft)
    399 455   return ft
    400 456   },
    skipped 319 lines
Please wait...
Page is in error, reload to recover