Compare commits

..

10 commits

Author SHA1 Message Date
Leon Mika 1f3c11f265 Fixed bug which made new columns non-resizable 2024-10-09 07:47:34 +11:00
Leon Mika f271865808 Updated readme 2024-10-08 14:11:27 +11:00
Leon Mika a1df99860e Added Ctrl+C and search previous command 2024-09-24 13:07:30 +10:00
Leon Mika 9267fc92a7 A few minor feature
Added automatic resizing of the columns when loading a model
Added colouring of the currently search for cell
2024-09-24 12:45:22 +10:00
Leon Mika 3ee6cbf514 Added 'each-row' and 'to-upper' commands 2022-10-05 11:49:37 +11:00
Leon Mika 5424a6b927 Allowed open-right to work on all columns
Previously it only worked on the right-most column.  Also bounded this command to "O" (this might change).
2022-07-01 16:15:54 +10:00
Leon Mika 975955236f Added support for TSV files
This uses a similar parser as CSV files, except configured for tabs.
Have also fixed the configured parser to ignore column counts.
2022-06-03 10:29:06 +10:00
Leon Mika 82face0010 Fixed some bugs with the backspace key mappings 2021-03-24 08:28:40 +11:00
Leon Mika 185900d626 Replaced termbox with tcell 2021-03-24 08:23:23 +11:00
Leon Mika e790b508a1 Some small quality of life improvements
- Added Ctrl+A and Ctrl+E for moving around the edit bar a.la. Emacs
- Added the "yank" and "paste" command to copy and paste a single cell value.  These are mapped to "y" and "p".
- Modified the edit bar so that entering commands is the only case where typing backspace on an empty edit bar will cancel out of edit mode
2020-10-21 14:39:59 +11:00
14 changed files with 616 additions and 101 deletions

View file

@ -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,28 +45,33 @@ 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 |
| `:` | Enter command |
| 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
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. |
| `open-down` | | Insert a new row below the currently selected row. |
| `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. |
| 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. |
| `open-down` | | Insert a new row below the currently selected row. |
| `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. |

View file

@ -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
}

View file

@ -106,13 +106,16 @@ func (frame *Frame) Error(err error) {
}
type PromptOptions struct {
Prompt string
InitialValue string
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
View file

@ -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
View file

@ -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
View file

@ -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}
},
}

View file

@ -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,19 +82,20 @@ 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()
for r := 0; r < rows; r++ {
record := make([]string, cols) // Reuse the record slice
record := make([]string, cols) // Reuse the record slice
for c := 0; c < cols; c++ {
record[c] = m.CellValue(r, c)
record[c] = m.CellValue(r, c)
}
if err := w.Write(record); err != nil {
f.Close()
@ -93,4 +110,56 @@ 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
}

View file

@ -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)

View file

@ -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
}

View file

@ -84,10 +84,10 @@ const (
KeyArrowLeft
KeyArrowRight
KeyBackspace = KeyCtrlH
KeyBackspace2 = KeyCtrl8
KeyEnter = KeyCtrlM
KeyEsc = KeyCtrl3
KeyBackspace = KeyCtrlH
KeyBackspace2
KeyEnter = KeyCtrlM
KeyEsc = KeyCtrl3
)
// The type of events supported by the driver

View file

@ -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 {
te.cancelAndExit()
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:]
}

View file

@ -3,7 +3,7 @@
package ui
import (
"github.com/nsf/termbox-go"
"github.com/gdamore/tcell/termbox"
)
type TermboxDriver struct {
@ -100,38 +100,36 @@ var termboxKeysToSpecialKeys = map[termbox.Key]rune{
termbox.KeyArrowDown: KeyArrowDown,
termbox.KeyArrowLeft: KeyArrowLeft,
termbox.KeyArrowRight: KeyArrowRight,
termbox.KeyCtrlSpace: KeyCtrlSpace,
termbox.KeyCtrlA: KeyCtrlA,
termbox.KeyCtrlB: KeyCtrlB,
termbox.KeyCtrlC: KeyCtrlC,
termbox.KeyCtrlD: KeyCtrlD,
termbox.KeyCtrlE: KeyCtrlE,
termbox.KeyCtrlF: KeyCtrlF,
termbox.KeyCtrlG: KeyCtrlG,
termbox.KeyCtrlH: KeyCtrlH,
termbox.KeyCtrlI: KeyCtrlI,
termbox.KeyCtrlJ: KeyCtrlJ,
termbox.KeyCtrlK: KeyCtrlK,
termbox.KeyCtrlL: KeyCtrlL,
termbox.KeyCtrlM: KeyCtrlM,
termbox.KeyCtrlN: KeyCtrlN,
termbox.KeyCtrlO: KeyCtrlO,
termbox.KeyCtrlP: KeyCtrlP,
termbox.KeyCtrlQ: KeyCtrlQ,
termbox.KeyCtrlR: KeyCtrlR,
termbox.KeyCtrlS: KeyCtrlS,
termbox.KeyCtrlT: KeyCtrlT,
termbox.KeyCtrlU: KeyCtrlU,
termbox.KeyCtrlV: KeyCtrlV,
termbox.KeyCtrlW: KeyCtrlW,
termbox.KeyCtrlX: KeyCtrlX,
termbox.KeyCtrlY: KeyCtrlY,
termbox.KeyCtrlZ: KeyCtrlZ,
termbox.KeyCtrl3: KeyCtrl3,
termbox.KeyCtrl4: KeyCtrl4,
termbox.KeyCtrl5: KeyCtrl5,
termbox.KeyCtrl6: KeyCtrl6,
termbox.KeyCtrl7: KeyCtrl7,
termbox.KeyCtrl8: KeyCtrl8,
termbox.KeyBackspace2: KeyBackspace2,
termbox.KeyCtrlA: KeyCtrlA,
termbox.KeyCtrlB: KeyCtrlB,
termbox.KeyCtrlC: KeyCtrlC,
termbox.KeyCtrlD: KeyCtrlD,
termbox.KeyCtrlE: KeyCtrlE,
termbox.KeyCtrlF: KeyCtrlF,
termbox.KeyCtrlG: KeyCtrlG,
termbox.KeyCtrlH: KeyCtrlH,
termbox.KeyCtrlI: KeyCtrlI,
termbox.KeyCtrlJ: KeyCtrlJ,
termbox.KeyCtrlK: KeyCtrlK,
termbox.KeyCtrlL: KeyCtrlL,
termbox.KeyCtrlM: KeyCtrlM,
termbox.KeyCtrlN: KeyCtrlN,
termbox.KeyCtrlO: KeyCtrlO,
termbox.KeyCtrlP: KeyCtrlP,
termbox.KeyCtrlQ: KeyCtrlQ,
termbox.KeyCtrlR: KeyCtrlR,
termbox.KeyCtrlS: KeyCtrlS,
termbox.KeyCtrlT: KeyCtrlT,
termbox.KeyCtrlU: KeyCtrlU,
termbox.KeyCtrlV: KeyCtrlV,
termbox.KeyCtrlW: KeyCtrlW,
termbox.KeyCtrlX: KeyCtrlX,
termbox.KeyCtrlY: KeyCtrlY,
termbox.KeyCtrlZ: KeyCtrlZ,
termbox.KeyCtrl3: KeyCtrl3,
termbox.KeyCtrl4: KeyCtrl4,
termbox.KeyCtrl5: KeyCtrl5,
termbox.KeyCtrl6: KeyCtrl6,
termbox.KeyCtrl7: KeyCtrl7,
}

View file

@ -2,12 +2,14 @@ package main
import (
"errors"
"regexp"
)
type ModelViewCtrl struct {
model Model
rowAttrs []SliceAttr
colAttrs []SliceAttr
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) {
gvm.colAttrs[col] = newAttrs
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
View 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)
}
}
}