Compare commits
10 commits
6e6e586f1d
...
1f3c11f265
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1f3c11f265 | ||
|
|
f271865808 | ||
|
|
a1df99860e | ||
|
|
9267fc92a7 | ||
|
|
3ee6cbf514 | ||
|
|
5424a6b927 | ||
|
|
975955236f | ||
|
|
82face0010 | ||
|
|
185900d626 | ||
|
|
e790b508a1 |
17
README.md
17
README.md
|
|
@ -15,10 +15,14 @@ go get github.com/lmika/ted
|
|||
## Usage
|
||||
|
||||
```
|
||||
ted <csvfile>
|
||||
ted [FLAGS] FILE
|
||||
```
|
||||
|
||||
Can either be a new CSV file, or an existing CSV file.
|
||||
Flags:
|
||||
|
||||
- `-c <codec>` the format that the file is in. Either `csv` or `tsv` files are supported. Default is `csv`
|
||||
|
||||
File can either be a new file, or an existing file.
|
||||
|
||||
TED is similar to Vim in that it is modal. After opening a file, the editor starts off in view mode, which permits navigating around.
|
||||
|
||||
|
|
@ -41,16 +45,20 @@ Editing:
|
|||
| `e` | Edit cell value |
|
||||
| `r` | Replace cell value |
|
||||
| `a` | Insert row below cursor and edit value |
|
||||
| `O` | Insert column to the right of cursor |
|
||||
| `D` | Delete current row |
|
||||
|
||||
Others:
|
||||
|
||||
| Key | Action |
|
||||
|:-----------|:--------------------|
|
||||
|:----|:--------------------------------------------|
|
||||
| `{` | Reduce cell width |
|
||||
| `}` | Increase cell width |
|
||||
| `/` | Search for cell matching regular expression |
|
||||
| `n` | Find next cell matching search |
|
||||
| `N` | Find previous cell matching search |
|
||||
| `y` | Copy cell value |
|
||||
| `p` | Paste cell value |
|
||||
| `:` | Enter command |
|
||||
|
||||
## Commands
|
||||
|
|
@ -58,7 +66,7 @@ Others:
|
|||
Commands can be entered by pressing `:` and typing in the command or alias.
|
||||
|
||||
| Command | Alias | Description |
|
||||
|:----------------|:-----------|:------------------------|
|
||||
|:----------------------|:-----------|:------------------------|
|
||||
| `save` | `w` | Save the current file. |
|
||||
| `quit` | `q` | Quit the application without saving changes. |
|
||||
| `save-and-quit` | `wq` | Save the current file and quit the application. |
|
||||
|
|
@ -66,3 +74,4 @@ Commands can be entered by pressing `:` and typing in the command or alias.
|
|||
| `open-right` | | Insert a new column to the right of the currently selected column. |
|
||||
| `delete-row` | | Delete the currently selected row. |
|
||||
| `delete-column` | | Delete the currently selected column. |
|
||||
|
||||
|
|
|
|||
201
commandmap.go
201
commandmap.go
|
|
@ -4,6 +4,7 @@ import (
|
|||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/lmika/shellwords"
|
||||
|
||||
|
|
@ -67,12 +68,16 @@ func (cm *CommandMapping) Eval(ctx *CommandContext, expr string) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
cmd := cm.Commands[toks[0]]
|
||||
return cm.Invoke(ctx, toks[0], toks[1:])
|
||||
}
|
||||
|
||||
func (cm *CommandMapping) Invoke(ctx *CommandContext, name string, args []string) error {
|
||||
cmd := cm.Commands[name]
|
||||
if cmd != nil {
|
||||
return cmd.Do(ctx.WithArgs(toks[1:]))
|
||||
return cmd.Do(ctx.WithArgs(args))
|
||||
}
|
||||
|
||||
return fmt.Errorf("no such command: %v", expr)
|
||||
return fmt.Errorf("no such command: %v", name)
|
||||
}
|
||||
|
||||
// Registers the standard view navigation commands. These commands require the frame
|
||||
|
|
@ -121,12 +126,17 @@ func (cm *CommandMapping) RegisterViewCommands() {
|
|||
})
|
||||
cm.Define("search", "Search for a cell", "", func(ctx *CommandContext) error {
|
||||
ctx.Frame().Prompt(PromptOptions{Prompt: "/"}, func(res string) error {
|
||||
if res == "" {
|
||||
ctx.Session().SetLastSearch(nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
re, err := regexp.Compile(res)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid regexp: %v", err)
|
||||
}
|
||||
|
||||
ctx.session.LastSearch = re
|
||||
ctx.Session().SetLastSearch(re)
|
||||
return ctx.Session().Commands.Eval(ctx, "search-next")
|
||||
})
|
||||
return nil
|
||||
|
|
@ -154,6 +164,32 @@ func (cm *CommandMapping) RegisterViewCommands() {
|
|||
}
|
||||
}
|
||||
})
|
||||
cm.Define("search-prev", "Goto the previous cell", "", func(ctx *CommandContext) error {
|
||||
if ctx.session.LastSearch == nil {
|
||||
ctx.Session().Commands.Eval(ctx, "search")
|
||||
}
|
||||
|
||||
height, width := ctx.ModelVC().Model().Dimensions()
|
||||
startX, startY := ctx.Frame().Grid().CellPosition()
|
||||
cellX, cellY := startX, startY
|
||||
|
||||
for {
|
||||
cellX--
|
||||
if cellX < 0 {
|
||||
cellX = width - 1
|
||||
cellY--
|
||||
if cellY < 0 {
|
||||
cellY = height - 1
|
||||
}
|
||||
}
|
||||
if ctx.session.LastSearch.MatchString(ctx.ModelVC().Model().CellValue(cellY, cellX)) {
|
||||
ctx.Frame().Grid().MoveTo(cellX, cellY)
|
||||
return nil
|
||||
} else if (cellX == startX) && (cellY == startY) {
|
||||
return errors.New("No match found")
|
||||
}
|
||||
}
|
||||
})
|
||||
cm.Define("x-replace", "Performs a search and replace", "", func(ctx *CommandContext) error {
|
||||
if len(ctx.Args()) != 2 {
|
||||
return errors.New("Usage: x-replace MATCH REPLACEMENT")
|
||||
|
|
@ -187,11 +223,7 @@ func (cm *CommandMapping) RegisterViewCommands() {
|
|||
grid := ctx.Frame().Grid()
|
||||
cellX, _ := grid.CellPosition()
|
||||
|
||||
height, width := ctx.ModelVC().Model().Dimensions()
|
||||
if cellX == width-1 {
|
||||
return ctx.ModelVC().Resize(height, width+1)
|
||||
}
|
||||
return nil
|
||||
return ctx.ModelVC().OpenRight(cellX)
|
||||
})
|
||||
|
||||
cm.Define("open-down", "Inserts a row below the curser", "", func(ctx *CommandContext) error {
|
||||
|
|
@ -251,6 +283,28 @@ func (cm *CommandMapping) RegisterViewCommands() {
|
|||
cm.Define("mark-row-red", "Set row marker to red", "", func(ctx *CommandContext) error {
|
||||
_, cellY := ctx.Frame().Grid().CellPosition()
|
||||
|
||||
namedArgs, _ := extractNamedArgs(ctx.args)
|
||||
if rowMatch, hasRowMatch := namedArgs["row-match"]; hasRowMatch {
|
||||
rows, cols := ctx.ModelVC().Model().Dimensions()
|
||||
|
||||
for r := 0; r < rows; r++ {
|
||||
rowMatches := false
|
||||
for c := 0; c < cols; c++ {
|
||||
cellVal := ctx.ModelVC().Model().CellValue(r, c)
|
||||
if cellVal == rowMatch {
|
||||
rowMatches = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if rowMatches {
|
||||
attrs := ctx.ModelVC().RowAttrs(r)
|
||||
attrs.Marker = MarkerRed
|
||||
ctx.ModelVC().SetRowAttrs(r, attrs)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
attrs := ctx.ModelVC().RowAttrs(cellY)
|
||||
attrs.Marker = MarkerRed
|
||||
ctx.ModelVC().SetRowAttrs(cellY, attrs)
|
||||
|
|
@ -276,7 +330,10 @@ func (cm *CommandMapping) RegisterViewCommands() {
|
|||
})
|
||||
|
||||
cm.Define("enter-command", "Enter command", "", func(ctx *CommandContext) error {
|
||||
ctx.Frame().Prompt(PromptOptions{Prompt: ":"}, func(res string) error {
|
||||
ctx.Frame().Prompt(PromptOptions{
|
||||
Prompt: ":",
|
||||
CancelOnEmptyBackspace: true,
|
||||
}, func(res string) error {
|
||||
return cm.Eval(ctx, res)
|
||||
})
|
||||
return nil
|
||||
|
|
@ -300,7 +357,15 @@ func (cm *CommandMapping) RegisterViewCommands() {
|
|||
grid := ctx.Frame().Grid()
|
||||
cellX, cellY := grid.CellPosition()
|
||||
|
||||
if _, isRwModel := ctx.ModelVC().Model().(RWModel); isRwModel {
|
||||
if _, isRwModel := ctx.ModelVC().Model().(RWModel); !isRwModel {
|
||||
return errors.New("Model is read-only")
|
||||
}
|
||||
|
||||
if len(ctx.Args()) == 1 {
|
||||
if err := ctx.ModelVC().SetCellValue(cellY, cellX, ctx.Args()[0]); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
ctx.Frame().Prompt(PromptOptions{
|
||||
Prompt: "> ",
|
||||
InitialValue: grid.Model().CellValue(cellX, cellY),
|
||||
|
|
@ -314,9 +379,89 @@ func (cm *CommandMapping) RegisterViewCommands() {
|
|||
}
|
||||
return nil
|
||||
})
|
||||
cm.Define("yank", "Yank cell value", "", func(ctx *CommandContext) error {
|
||||
grid := ctx.Frame().Grid()
|
||||
cellX, cellY := grid.CellPosition()
|
||||
|
||||
// TODO: allow ranges
|
||||
ctx.Session().pasteBoard.SetCellValue(0, 0, grid.Model().CellValue(cellX, cellY))
|
||||
|
||||
return nil
|
||||
})
|
||||
cm.Define("paste", "Paste cell value", "", func(ctx *CommandContext) error {
|
||||
grid := ctx.Frame().Grid()
|
||||
cellX, cellY := grid.CellPosition()
|
||||
|
||||
// TODO: allow ranges
|
||||
if _, isRwModel := ctx.ModelVC().Model().(RWModel); !isRwModel {
|
||||
return errors.New("Model is read-only")
|
||||
}
|
||||
if err := ctx.ModelVC().SetCellValue(cellY, cellX, ctx.Session().pasteBoard.CellValue(0, 0)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
cm.Define("to-upper", "Convert cell value to uppercase", "", func(ctx *CommandContext) error {
|
||||
grid := ctx.Frame().Grid()
|
||||
cellX, cellY := grid.CellPosition()
|
||||
|
||||
// TODO: allow ranges
|
||||
|
||||
if _, isRwModel := ctx.ModelVC().Model().(RWModel); !isRwModel {
|
||||
return errors.New("Model is read-only")
|
||||
}
|
||||
|
||||
currentValue := ctx.ModelVC().Model().CellValue(cellY, cellX)
|
||||
newValue := strings.ToUpper(currentValue)
|
||||
if err := ctx.ModelVC().SetCellValue(cellY, cellX, newValue); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
cm.Define("each-row", "Executes the command for each row in the column", "", func(ctx *CommandContext) error {
|
||||
if len(ctx.args) != 1 {
|
||||
return errors.New("Sub-command required")
|
||||
}
|
||||
|
||||
grid := ctx.Frame().Grid()
|
||||
rows, _ := ctx.ModelVC().Model().Dimensions()
|
||||
|
||||
cellX, cellY := grid.CellPosition()
|
||||
defer grid.MoveTo(cellX, cellY)
|
||||
|
||||
subCommand := ctx.args
|
||||
|
||||
for r := 0; r < rows; r++ {
|
||||
grid.MoveTo(cellX, r)
|
||||
|
||||
if err := ctx.Session().Commands.Invoke(ctx, subCommand[0], subCommand[1:]); err != nil {
|
||||
return fmt.Errorf("at [%d, %d]: %v", cellX, r, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
cm.Define("save", "Save current file", "", func(ctx *CommandContext) error {
|
||||
wSource, isWSource := ctx.Session().Source.(WritableModelSource)
|
||||
var source ModelSource
|
||||
if len(ctx.args) >= 2 {
|
||||
targetCodecName := ctx.args[0]
|
||||
codecBuilder, hasCodec := codecModelSourceBuilders[targetCodecName]
|
||||
if !hasCodec {
|
||||
return fmt.Errorf("unrecognsed codec: %v", targetCodecName)
|
||||
}
|
||||
|
||||
targetFilename := ctx.args[1]
|
||||
source = codecBuilder(targetFilename)
|
||||
} else {
|
||||
source = ctx.Session().Source
|
||||
}
|
||||
|
||||
wSource, isWSource := source.(WritableModelSource)
|
||||
if !isWSource {
|
||||
return fmt.Errorf("model is not writable")
|
||||
}
|
||||
|
|
@ -326,6 +471,7 @@ func (cm *CommandMapping) RegisterViewCommands() {
|
|||
}
|
||||
|
||||
ctx.Frame().Message("Wrote " + wSource.String())
|
||||
ctx.Session().Source = wSource
|
||||
return nil
|
||||
})
|
||||
|
||||
|
|
@ -362,6 +508,7 @@ func (cm *CommandMapping) RegisterViewKeyBindings() {
|
|||
cm.MapKey(ui.KeyCtrlK, cm.Command("row-bottom"))
|
||||
cm.MapKey(ui.KeyCtrlJ, cm.Command("col-left"))
|
||||
cm.MapKey(ui.KeyCtrlL, cm.Command("col-right"))
|
||||
cm.MapKey(ui.KeyCtrlC, cm.Command("quit"))
|
||||
|
||||
cm.MapKey(ui.KeyArrowUp, cm.Command("move-up"))
|
||||
cm.MapKey(ui.KeyArrowDown, cm.Command("move-down"))
|
||||
|
|
@ -373,10 +520,15 @@ func (cm *CommandMapping) RegisterViewKeyBindings() {
|
|||
|
||||
cm.MapKey('a', cm.Command("append"))
|
||||
|
||||
cm.MapKey('O', cm.Command("open-right"))
|
||||
cm.MapKey('D', cm.Command("delete-row"))
|
||||
|
||||
cm.MapKey('/', cm.Command("search"))
|
||||
cm.MapKey('n', cm.Command("search-next"))
|
||||
cm.MapKey('N', cm.Command("search-prev"))
|
||||
|
||||
cm.MapKey('y', cm.Command("yank"))
|
||||
cm.MapKey('p', cm.Command("paste"))
|
||||
|
||||
cm.MapKey('0', cm.Command("clear-row-marker"))
|
||||
cm.MapKey('1', cm.Command("mark-row-red"))
|
||||
|
|
@ -398,3 +550,28 @@ func gridNavOperation(op func(grid *ui.Grid)) func(ctx *CommandContext) error {
|
|||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// extractNamedArgs will extract out any arguments of the form '-name value' and stick it in a map.
|
||||
// Any non-named arguments will be extracted out as positional. If an argument only has a name, it
|
||||
// will be added to the map with the empty string as the value.
|
||||
func extractNamedArgs(args []string) (namedArgs map[string]string, positionalArgs []string) {
|
||||
for len(args) > 0 {
|
||||
if args[0][0] == '-' {
|
||||
if namedArgs == nil {
|
||||
namedArgs = make(map[string]string)
|
||||
}
|
||||
|
||||
if len(args) >= 2 && args[1][0] != '-' {
|
||||
namedArgs[args[0][1:]] = args[1]
|
||||
args = args[2:]
|
||||
} else {
|
||||
namedArgs[args[0][1:]] = ""
|
||||
args = args[1:]
|
||||
}
|
||||
} else {
|
||||
positionalArgs = append(positionalArgs, args[0])
|
||||
args = args[1:]
|
||||
}
|
||||
}
|
||||
return namedArgs, positionalArgs
|
||||
}
|
||||
|
|
|
|||
3
frame.go
3
frame.go
|
|
@ -108,11 +108,14 @@ func (frame *Frame) Error(err error) {
|
|||
type PromptOptions struct {
|
||||
Prompt string
|
||||
InitialValue string
|
||||
CancelOnEmptyBackspace bool
|
||||
}
|
||||
|
||||
// Prompt the user for input. This switches the mode to entry mode.
|
||||
func (frame *Frame) Prompt(options PromptOptions, callback func(res string) error) {
|
||||
frame.textEntry.Reset()
|
||||
frame.textEntry.Prompt = options.Prompt
|
||||
frame.textEntry.CancelOnEmptyBackspace = options.CancelOnEmptyBackspace
|
||||
frame.textEntry.SetValue(options.InitialValue)
|
||||
|
||||
frame.textEntry.OnCancel = frame.exitEntryMode
|
||||
|
|
|
|||
18
go.mod
18
go.mod
|
|
@ -1,9 +1,21 @@
|
|||
module github.com/lmika/ted
|
||||
|
||||
go 1.15
|
||||
go 1.22
|
||||
|
||||
require (
|
||||
github.com/gdamore/tcell v1.4.0
|
||||
github.com/lmika/shellwords v0.0.0-20140714114018-ce258dd729fe
|
||||
github.com/mattn/go-runewidth v0.0.9 // indirect
|
||||
github.com/nsf/termbox-go v0.0.0-20200418040025-38ba6e5628f1
|
||||
github.com/stretchr/testify v1.7.5
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/gdamore/encoding v1.0.0 // indirect
|
||||
github.com/lucasb-eyer/go-colorful v1.0.3 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.10 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/rivo/uniseg v0.2.0 // indirect
|
||||
golang.org/x/sys v0.0.0-20190626150813-e07cf5db2756 // indirect
|
||||
golang.org/x/text v0.3.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
|
|
|||
35
go.sum
35
go.sum
|
|
@ -1,6 +1,33 @@
|
|||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/gdamore/encoding v1.0.0 h1:+7OoQ1Bc6eTm5niUzBa0Ctsh6JbMW6Ra+YNuAtDBdko=
|
||||
github.com/gdamore/encoding v1.0.0/go.mod h1:alR0ol34c49FCSBLjhosxzcPHQbf2trDkoo5dl+VrEg=
|
||||
github.com/gdamore/tcell v1.4.0 h1:vUnHwJRvcPQa3tzi+0QI4U9JINXYJlOz9yiaiPQ2wMU=
|
||||
github.com/gdamore/tcell v1.4.0/go.mod h1:vxEiSDZdW3L+Uhjii9c3375IlDmR05bzxY404ZVSMo0=
|
||||
github.com/lmika/shellwords v0.0.0-20140714114018-ce258dd729fe h1:1UXS/6OFkbi6JrihPykmYO1VtsABB02QQ+YmYYzTY18=
|
||||
github.com/lmika/shellwords v0.0.0-20140714114018-ce258dd729fe/go.mod h1:qpdOkLougV5Yry4Px9f1w1pNMavcr6Z67VW5Ro+vW5I=
|
||||
github.com/mattn/go-runewidth v0.0.9 h1:Lm995f3rfxdpd6TSmuVCHVb/QhupuXlYr8sCI/QdE+0=
|
||||
github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI=
|
||||
github.com/nsf/termbox-go v0.0.0-20200418040025-38ba6e5628f1 h1:lh3PyZvY+B9nFliSGTn5uFuqQQJGuNrD0MLCokv09ag=
|
||||
github.com/nsf/termbox-go v0.0.0-20200418040025-38ba6e5628f1/go.mod h1:IuKpRQcYE1Tfu+oAQqaLisqDeXgjyyltCfsaoYN18NQ=
|
||||
github.com/lucasb-eyer/go-colorful v1.0.3 h1:QIbQXiugsb+q10B+MI+7DI1oQLdmnep86tWFlaaUAac=
|
||||
github.com/lucasb-eyer/go-colorful v1.0.3/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||
github.com/mattn/go-runewidth v0.0.7/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI=
|
||||
github.com/mattn/go-runewidth v0.0.10 h1:CoZ3S2P7pvtP45xOtBw+/mDL2z0RKI576gSkzRRpdGg=
|
||||
github.com/mattn/go-runewidth v0.0.10/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
|
||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.5 h1:s5PTfem8p8EbKQOctVV53k6jCJt3UX4IEJzwh+C324Q=
|
||||
github.com/stretchr/testify v1.7.5/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
golang.org/x/sys v0.0.0-20190626150813-e07cf5db2756 h1:9nuHUbU8dRnRRfj9KjWUVrJeoexdbeMjttk6Oh1rD10=
|
||||
golang.org/x/sys v0.0.0-20190626150813-e07cf5db2756/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
|
|
|||
26
main.go
26
main.go
|
|
@ -1,13 +1,14 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"github.com/lmika/ted/ui"
|
||||
"flag"
|
||||
"fmt"
|
||||
"github.com/lmika/ted/ui"
|
||||
"os"
|
||||
)
|
||||
|
||||
func main() {
|
||||
var flagCodec = flag.String("c", "csv", "file codec to use")
|
||||
flag.Parse()
|
||||
if flag.NArg() == 0 {
|
||||
fmt.Fprintln(os.Stderr, "usage: ted FILENAME")
|
||||
|
|
@ -20,12 +21,33 @@ func main() {
|
|||
}
|
||||
defer uiManager.Close()
|
||||
|
||||
codecBuilder, hasCodec := codecModelSourceBuilders[*flagCodec]
|
||||
if !hasCodec {
|
||||
fmt.Fprintf(os.Stderr, "unrecognised codec: %v", *flagCodec)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
frame := NewFrame(uiManager)
|
||||
session := NewSession(uiManager, frame, CsvFileModelSource{flag.Arg(0)})
|
||||
session := NewSession(uiManager, frame, codecBuilder(flag.Arg(0)))
|
||||
session.LoadFromSource()
|
||||
session.ResizeToBestFit()
|
||||
|
||||
uiManager.SetRootComponent(frame.RootComponent())
|
||||
frame.enterMode(GridMode)
|
||||
|
||||
uiManager.Loop()
|
||||
}
|
||||
|
||||
type codecModelSourceBuilder func(filename string) ModelSource
|
||||
|
||||
var codecModelSourceBuilders = map[string]codecModelSourceBuilder{
|
||||
"csv": func(filename string) ModelSource {
|
||||
return NewCsvFileModelSource(filename, CsvFileModelSourceOptions{Comma: ','})
|
||||
},
|
||||
"tsv": func(filename string) ModelSource {
|
||||
return NewCsvFileModelSource(filename, CsvFileModelSourceOptions{Comma: '\t'})
|
||||
},
|
||||
"jira": func(filename string) ModelSource {
|
||||
return JiraTableModelSource{Filename: filename, Header: true}
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,13 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"os"
|
||||
"encoding/csv"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ModelSource is a source of models. At a minimum, it must be able to read models.
|
||||
|
|
@ -26,22 +29,34 @@ type WritableModelSource interface {
|
|||
|
||||
// A model source backed by a CSV file
|
||||
type CsvFileModelSource struct {
|
||||
Filename string
|
||||
filename string
|
||||
options CsvFileModelSourceOptions
|
||||
}
|
||||
|
||||
type CsvFileModelSourceOptions struct {
|
||||
Comma rune
|
||||
}
|
||||
|
||||
func NewCsvFileModelSource(filename string, options CsvFileModelSourceOptions) CsvFileModelSource {
|
||||
return CsvFileModelSource{
|
||||
filename: filename,
|
||||
options: options,
|
||||
}
|
||||
}
|
||||
|
||||
// Describes the source
|
||||
func (s CsvFileModelSource) String() string {
|
||||
return filepath.Base(s.Filename)
|
||||
return filepath.Base(s.filename)
|
||||
}
|
||||
|
||||
// Read the model from the given source
|
||||
func (s CsvFileModelSource) Read() (Model, error) {
|
||||
// Check if the file exists. If not, return an empty model
|
||||
if _, err := os.Stat(s.Filename); os.IsNotExist(err) {
|
||||
if _, err := os.Stat(s.filename); os.IsNotExist(err) {
|
||||
return NewSingleCellStdModel(), nil
|
||||
}
|
||||
|
||||
f, err := os.Open(s.Filename)
|
||||
f, err := os.Open(s.filename)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
@ -49,12 +64,13 @@ func (s CsvFileModelSource) Read() (Model, error) {
|
|||
|
||||
model := new(StdModel)
|
||||
r := csv.NewReader(f)
|
||||
r.Comma = s.options.Comma
|
||||
r.FieldsPerRecord = -1
|
||||
for {
|
||||
record, err := r.Read()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
} else if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
|
|
@ -66,12 +82,13 @@ func (s CsvFileModelSource) Read() (Model, error) {
|
|||
}
|
||||
|
||||
func (s CsvFileModelSource) Write(m Model) error {
|
||||
f, err := os.Create(s.Filename)
|
||||
f, err := os.Create(s.filename)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
w := csv.NewWriter(f)
|
||||
w.Comma = s.options.Comma
|
||||
|
||||
rows, cols := m.Dimensions()
|
||||
|
||||
|
|
@ -94,3 +111,55 @@ func (s CsvFileModelSource) Write(m Model) error {
|
|||
|
||||
return f.Close()
|
||||
}
|
||||
|
||||
type JiraTableModelSource struct {
|
||||
Filename string
|
||||
Header bool
|
||||
}
|
||||
|
||||
func (s JiraTableModelSource) String() string {
|
||||
return filepath.Base(s.Filename)
|
||||
}
|
||||
|
||||
func (JiraTableModelSource) Read() (Model, error) {
|
||||
return nil, errors.New("read not supported yet")
|
||||
}
|
||||
|
||||
func (s JiraTableModelSource) Write(m Model) error {
|
||||
f, err := os.Create(s.Filename)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
rows, cols := m.Dimensions()
|
||||
line := new(strings.Builder)
|
||||
|
||||
for r := 0; r < rows; r++ {
|
||||
sep := "|"
|
||||
if r == 0 && s.Header {
|
||||
sep = "||"
|
||||
}
|
||||
|
||||
line.Reset()
|
||||
line.WriteString(sep)
|
||||
line.WriteRune(' ')
|
||||
|
||||
for c := 0; c < cols; c++ {
|
||||
if c >= 1 {
|
||||
line.WriteRune(' ')
|
||||
line.WriteString(sep)
|
||||
line.WriteRune(' ')
|
||||
}
|
||||
line.WriteString(m.CellValue(r, c))
|
||||
}
|
||||
|
||||
line.WriteRune(' ')
|
||||
line.WriteString(sep)
|
||||
if _, err := fmt.Fprintln(f, line.String()); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
|
|
|||
36
session.go
36
session.go
|
|
@ -6,6 +6,12 @@ import (
|
|||
"github.com/lmika/ted/ui"
|
||||
)
|
||||
|
||||
const (
|
||||
bestFixRightMargin = 2
|
||||
bestFixMinColumnSize = 8
|
||||
bestFixMaxColumnSize = 32
|
||||
)
|
||||
|
||||
// The session is responsible for managing the UI and the model and handling
|
||||
// the interaction between the two and the user.
|
||||
type Session struct {
|
||||
|
|
@ -15,6 +21,7 @@ type Session struct {
|
|||
Commands *CommandMapping
|
||||
UIManager *ui.Ui
|
||||
modelController *ModelViewCtrl
|
||||
pasteBoard RWModel
|
||||
|
||||
LastSearch *regexp.Regexp
|
||||
}
|
||||
|
|
@ -28,6 +35,7 @@ func NewSession(uiManager *ui.Ui, frame *Frame, source ModelSource) *Session {
|
|||
Commands: NewCommandMapping(),
|
||||
UIManager: uiManager,
|
||||
modelController: NewGridViewModel(model),
|
||||
pasteBoard: NewSingleCellStdModel(),
|
||||
}
|
||||
|
||||
frame.SetModel(&SessionGridModel{session.modelController})
|
||||
|
|
@ -53,6 +61,23 @@ func (session *Session) LoadFromSource() {
|
|||
session.modelController.SetModel(newModel)
|
||||
}
|
||||
|
||||
func (sesion *Session) ResizeToBestFit() {
|
||||
mr, mc := sesion.model.Dimensions()
|
||||
colMaxSize := make([]int, mc)
|
||||
for c := 0; c < mc; c++ {
|
||||
colMaxSize[c] = bestFixMinColumnSize
|
||||
for r := 0; r < mr; r++ {
|
||||
colMaxSize[c] = max(colMaxSize[c], len(sesion.model.CellValue(r, c))+bestFixRightMargin)
|
||||
}
|
||||
}
|
||||
|
||||
for c, ml := range colMaxSize {
|
||||
sesion.modelController.SetColAttrs(c, SliceAttr{
|
||||
Size: min(ml, bestFixMaxColumnSize),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Input from the frame
|
||||
func (session *Session) KeyPressed(key rune, mod int) {
|
||||
// Add the mod key modifier
|
||||
|
|
@ -69,6 +94,11 @@ func (session *Session) KeyPressed(key rune, mod int) {
|
|||
}
|
||||
}
|
||||
|
||||
func (s *Session) SetLastSearch(re *regexp.Regexp) {
|
||||
s.LastSearch = re
|
||||
s.modelController.lastSearchRegexp = re
|
||||
}
|
||||
|
||||
// The command context used by the session
|
||||
type CommandContext struct {
|
||||
session *Session
|
||||
|
|
@ -130,6 +160,12 @@ func (sgm *SessionGridModel) CellValue(x int, y int) string {
|
|||
}
|
||||
|
||||
func (sgm *SessionGridModel) CellAttributes(x int, y int) (fg, bg ui.Attribute) {
|
||||
if sgm.GridViewModel.lastSearchRegexp != nil {
|
||||
if sgm.GridViewModel.lastSearchRegexp.MatchString(sgm.GridViewModel.Model().CellValue(y, x)) {
|
||||
return ui.ColorMagenta, 0
|
||||
}
|
||||
}
|
||||
|
||||
rowAttrs := sgm.GridViewModel.RowAttrs(y)
|
||||
colAttrs := sgm.GridViewModel.ColAttrs(y)
|
||||
|
||||
|
|
|
|||
12
stdmodel.go
12
stdmodel.go
|
|
@ -14,12 +14,20 @@ type StdModel struct {
|
|||
//
|
||||
func NewSingleCellStdModel() *StdModel {
|
||||
sm := new(StdModel)
|
||||
sm.appendStr([]string { "" })
|
||||
sm.appendStr([]string{""})
|
||||
sm.dirty = false
|
||||
|
||||
return sm
|
||||
}
|
||||
|
||||
func NewStdModelFromSlice(str [][]string) *StdModel {
|
||||
sm := new(StdModel)
|
||||
for _, r := range str {
|
||||
sm.appendStr(r)
|
||||
}
|
||||
return sm
|
||||
}
|
||||
|
||||
/**
|
||||
* The dimensions of the model (height, width).
|
||||
*/
|
||||
|
|
@ -73,7 +81,7 @@ func (sm *StdModel) SetCellValue(r, c int, value string) {
|
|||
func (sm *StdModel) appendStr(row []string) {
|
||||
if len(sm.Cells) == 0 {
|
||||
cells := sm.strSliceToCell(row, len(row))
|
||||
sm.Cells = [][]Cell{ cells }
|
||||
sm.Cells = [][]Cell{cells}
|
||||
return
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -85,7 +85,7 @@ const (
|
|||
KeyArrowRight
|
||||
|
||||
KeyBackspace = KeyCtrlH
|
||||
KeyBackspace2 = KeyCtrl8
|
||||
KeyBackspace2
|
||||
KeyEnter = KeyCtrlM
|
||||
KeyEsc = KeyCtrl3
|
||||
)
|
||||
|
|
|
|||
|
|
@ -57,6 +57,11 @@ type TextEntry struct {
|
|||
value string
|
||||
cursorOffset int
|
||||
displayOffset int
|
||||
isDirty bool
|
||||
|
||||
// CancelOnEmptyBackspace will cancel the text entry prompt if no other
|
||||
// key was pressed and the prompt was empty.
|
||||
CancelOnEmptyBackspace bool
|
||||
|
||||
// Called when the user presses Enter
|
||||
OnEntry func(val string)
|
||||
|
|
@ -65,6 +70,10 @@ type TextEntry struct {
|
|||
OnCancel func()
|
||||
}
|
||||
|
||||
func (te *TextEntry) Reset() {
|
||||
te.isDirty = false
|
||||
}
|
||||
|
||||
func (te *TextEntry) Remeasure(w, h int) (int, int) {
|
||||
return w, 1
|
||||
}
|
||||
|
|
@ -116,16 +125,18 @@ func (te *TextEntry) KeyPressed(key rune, mod int) {
|
|||
te.moveCursorBy(-1)
|
||||
} else if key == KeyArrowRight {
|
||||
te.moveCursorBy(1)
|
||||
} else if key == KeyHome {
|
||||
} else if (key == KeyHome) || (key == KeyCtrlA) {
|
||||
te.moveCursorTo(0)
|
||||
} else if key == KeyEnd {
|
||||
} else if (key == KeyEnd) || (key == KeyCtrlE) {
|
||||
te.moveCursorTo(len(te.value))
|
||||
} else if (key == KeyBackspace) || (key == KeyBackspace2) {
|
||||
if mod&ModKeyAlt != 0 {
|
||||
te.backspaceWhile(unicode.IsSpace)
|
||||
te.backspaceWhile(func(r rune) bool { return !unicode.IsSpace(r) })
|
||||
} else if te.cursorOffset == 0 {
|
||||
if te.CancelOnEmptyBackspace && !te.isDirty {
|
||||
te.cancelAndExit()
|
||||
}
|
||||
} else {
|
||||
te.backspace()
|
||||
}
|
||||
|
|
@ -171,6 +182,7 @@ func (te *TextEntry) backspaceWhile(guard func(r rune) bool) {
|
|||
// Kill the line. If the cursor is at the end of the line, kill to the start.
|
||||
// Otherwise, trim the line.
|
||||
func (te *TextEntry) killLine() {
|
||||
te.isDirty = true
|
||||
if te.cursorOffset < len(te.value) {
|
||||
te.value = te.value[:te.cursorOffset]
|
||||
} else {
|
||||
|
|
@ -181,6 +193,7 @@ func (te *TextEntry) killLine() {
|
|||
|
||||
// Inserts a rune at the cursor position
|
||||
func (te *TextEntry) insertRune(key rune) {
|
||||
te.isDirty = true
|
||||
if te.cursorOffset >= len(te.value) {
|
||||
te.value += string(key)
|
||||
} else {
|
||||
|
|
@ -191,6 +204,7 @@ func (te *TextEntry) insertRune(key rune) {
|
|||
|
||||
// Remove the character at a specific position
|
||||
func (te *TextEntry) removeCharAtPos(pos int) {
|
||||
te.isDirty = true
|
||||
if (pos >= 0) && (pos < len(te.value)) {
|
||||
te.value = te.value[:pos] + te.value[pos+1:]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
package ui
|
||||
|
||||
import (
|
||||
"github.com/nsf/termbox-go"
|
||||
"github.com/gdamore/tcell/termbox"
|
||||
)
|
||||
|
||||
type TermboxDriver struct {
|
||||
|
|
@ -100,8 +100,7 @@ var termboxKeysToSpecialKeys = map[termbox.Key]rune{
|
|||
termbox.KeyArrowDown: KeyArrowDown,
|
||||
termbox.KeyArrowLeft: KeyArrowLeft,
|
||||
termbox.KeyArrowRight: KeyArrowRight,
|
||||
|
||||
termbox.KeyCtrlSpace: KeyCtrlSpace,
|
||||
termbox.KeyBackspace2: KeyBackspace2,
|
||||
termbox.KeyCtrlA: KeyCtrlA,
|
||||
termbox.KeyCtrlB: KeyCtrlB,
|
||||
termbox.KeyCtrlC: KeyCtrlC,
|
||||
|
|
@ -133,5 +132,4 @@ var termboxKeysToSpecialKeys = map[termbox.Key]rune{
|
|||
termbox.KeyCtrl5: KeyCtrl5,
|
||||
termbox.KeyCtrl6: KeyCtrl6,
|
||||
termbox.KeyCtrl7: KeyCtrl7,
|
||||
termbox.KeyCtrl8: KeyCtrl8,
|
||||
}
|
||||
|
|
|
|||
42
viewmodel.go
42
viewmodel.go
|
|
@ -2,12 +2,14 @@ package main
|
|||
|
||||
import (
|
||||
"errors"
|
||||
"regexp"
|
||||
)
|
||||
|
||||
type ModelViewCtrl struct {
|
||||
model Model
|
||||
rowAttrs []SliceAttr
|
||||
colAttrs []SliceAttr
|
||||
lastSearchRegexp *regexp.Regexp
|
||||
}
|
||||
|
||||
func NewGridViewModel(model Model) *ModelViewCtrl {
|
||||
|
|
@ -16,6 +18,10 @@ func NewGridViewModel(model Model) *ModelViewCtrl {
|
|||
return gvm
|
||||
}
|
||||
|
||||
func (gvm *ModelViewCtrl) SetLastSearchRegexp(r *regexp.Regexp) {
|
||||
gvm.lastSearchRegexp = r
|
||||
}
|
||||
|
||||
func (gvm *ModelViewCtrl) Model() Model {
|
||||
return gvm.model
|
||||
}
|
||||
|
|
@ -44,7 +50,9 @@ func (gvm *ModelViewCtrl) SetRowAttrs(row int, newAttrs SliceAttr) {
|
|||
}
|
||||
|
||||
func (gvm *ModelViewCtrl) SetColAttrs(col int, newAttrs SliceAttr) {
|
||||
if col >= 0 && col < len(gvm.colAttrs) {
|
||||
gvm.colAttrs[col] = newAttrs
|
||||
}
|
||||
}
|
||||
|
||||
func (gvm *ModelViewCtrl) SetCellValue(r, c int, newValue string) error {
|
||||
|
|
@ -69,6 +77,40 @@ func (gvm *ModelViewCtrl) Resize(newRow, newCol int) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
func (gvm *ModelViewCtrl) OpenRight(col int) error {
|
||||
if col < 0 {
|
||||
return errors.New("col out of bound")
|
||||
}
|
||||
return gvm.insertColumn(col + 1)
|
||||
}
|
||||
|
||||
func (gvm *ModelViewCtrl) insertColumn(col int) error {
|
||||
rwModel, isRWModel := gvm.model.(RWModel)
|
||||
if !isRWModel {
|
||||
return ErrModelReadOnly
|
||||
}
|
||||
|
||||
dr, dc := rwModel.Dimensions()
|
||||
if col < 0 || col > dc {
|
||||
return errors.New("col out of bound")
|
||||
}
|
||||
|
||||
rwModel.Resize(dr, dc+1)
|
||||
|
||||
for c := dc; c >= col; c-- {
|
||||
for r := 0; r < dr; r++ {
|
||||
if c == col {
|
||||
rwModel.SetCellValue(r, c, "")
|
||||
} else {
|
||||
rwModel.SetCellValue(r, c, rwModel.CellValue(r, c-1))
|
||||
}
|
||||
}
|
||||
}
|
||||
gvm.modelWasResized()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Deletes a row of a model
|
||||
func (gvm *ModelViewCtrl) DeleteRow(row int) error {
|
||||
rwModel, isRWModel := gvm.model.(RWModel)
|
||||
|
|
|
|||
98
viewmodel_test.go
Normal file
98
viewmodel_test.go
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestModelViewCtrl_OpenRight(t *testing.T) {
|
||||
t.Run("should move cols to the right within the model", func(t *testing.T) {
|
||||
rwModel := NewStdModelFromSlice([][]string{
|
||||
{"letters", "numbers", "greek"},
|
||||
{"a", "1", "alpha"},
|
||||
{"b", "2", "bravo"},
|
||||
{"c", "3", "charlie"},
|
||||
})
|
||||
|
||||
mvc := NewGridViewModel(rwModel)
|
||||
err := mvc.OpenRight(1)
|
||||
assert.NoError(t, err)
|
||||
|
||||
assertModel(t, rwModel, [][]string{
|
||||
{"letters", "numbers", "", "greek"},
|
||||
{"a", "1", "", "alpha"},
|
||||
{"b", "2", "", "bravo"},
|
||||
{"c", "3", "", "charlie"},
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("should move cols to the right at the left of the model", func(t *testing.T) {
|
||||
rwModel := NewStdModelFromSlice([][]string{
|
||||
{"letters", "numbers", "greek"},
|
||||
{"a", "1", "alpha"},
|
||||
{"b", "2", "bravo"},
|
||||
{"c", "3", "charlie"},
|
||||
})
|
||||
|
||||
mvc := NewGridViewModel(rwModel)
|
||||
err := mvc.OpenRight(0)
|
||||
assert.NoError(t, err)
|
||||
|
||||
assertModel(t, rwModel, [][]string{
|
||||
{"letters", "", "numbers", "greek"},
|
||||
{"a", "", "1", "alpha"},
|
||||
{"b", "", "2", "bravo"},
|
||||
{"c", "", "3", "charlie"},
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("should move cols to the right at the right of the model", func(t *testing.T) {
|
||||
rwModel := NewStdModelFromSlice([][]string{
|
||||
{"letters", "numbers", "greek"},
|
||||
{"a", "1", "alpha"},
|
||||
{"b", "2", "bravo"},
|
||||
{"c", "3", "charlie"},
|
||||
})
|
||||
|
||||
mvc := NewGridViewModel(rwModel)
|
||||
err := mvc.OpenRight(2)
|
||||
assert.NoError(t, err)
|
||||
|
||||
assertModel(t, rwModel, [][]string{
|
||||
{"letters", "numbers", "greek", ""},
|
||||
{"a", "1", "alpha", ""},
|
||||
{"b", "2", "bravo", ""},
|
||||
{"c", "3", "charlie", ""},
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("should return error if row out of bounds", func(t *testing.T) {
|
||||
scenario := []int{-1, 3, 12}
|
||||
for _, scenario := range scenario {
|
||||
t.Run(fmt.Sprint(scenario), func(t *testing.T) {
|
||||
rwModel := NewStdModelFromSlice([][]string{
|
||||
{"letters", "numbers", "greek"},
|
||||
{"a", "1", "alpha"},
|
||||
{"b", "2", "bravo"},
|
||||
{"c", "3", "charlie"},
|
||||
})
|
||||
mvc := NewGridViewModel(rwModel)
|
||||
err := mvc.OpenRight(scenario)
|
||||
assert.Error(t, err)
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func assertModel(t *testing.T, actual Model, expected [][]string) {
|
||||
dr, dc := actual.Dimensions()
|
||||
assert.Equalf(t, len(expected), dr, "number of rows in model")
|
||||
|
||||
for r, row := range expected {
|
||||
assert.Equalf(t, len(row), dc, "number of cols in row %v", r)
|
||||
for c, cell := range row {
|
||||
assert.Equalf(t, cell, actual.CellValue(r, c), "cell value at row %v, col %v", r, c)
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue