From ae833d5db8bbbd04b80857d5c8e39d6354a49cd3 Mon Sep 17 00:00:00 2001 From: Leon Mika Date: Sat, 1 Sep 2018 11:27:34 +1000 Subject: [PATCH] Have actually implemented useful commands for reading/writing CSV files --- commandmap.go | 52 +++++++++++++++++++++++++--- frame.go | 10 ++++-- main.go | 6 ++-- model.go | 16 ++++----- modelsource.go | 91 +++++++++++++++++++++++++++++++++++++++++++++++++ session.go | 24 +++++++++++-- stdmodel.go | 92 ++++++++++++++++++++++++++++++++++---------------- ui/driver.go | 2 +- ui/stdcomps.go | 14 ++++++-- 9 files changed, 252 insertions(+), 55 deletions(-) create mode 100644 modelsource.go diff --git a/commandmap.go b/commandmap.go index bf96b76..24cd5fd 100644 --- a/commandmap.go +++ b/commandmap.go @@ -2,6 +2,7 @@ package main import ( "bitbucket.org/lmika/ted-v2/ui" + "fmt" ) const ( @@ -53,6 +54,23 @@ func (cm *CommandMapping) KeyMapping(key rune) *Command { return cm.KeyMappings[key] } +// Evaluate a command +func (cm *CommandMapping) Eval(ctx *CommandContext, expr string) error { + // TODO: Use propper expression language here + cmd := cm.Commands[expr] + if cmd != nil { + return cmd.Do(ctx) + } + + return fmt.Errorf("no such command: %v", expr) +} + +func (cm *CommandMapping) DoEval(ctx *CommandContext, expr string) { + if err := cm.Eval(ctx, expr); err != nil { + ctx.ShowError(err) + } +} + // Registers the standard view navigation commands. These commands require the frame func (cm *CommandMapping) RegisterViewCommands() { cm.Define("move-down", "Moves the cursor down one row", "", gridNavOperation(func(grid *ui.Grid) { grid.MoveBy(0, 1) })) @@ -87,8 +105,8 @@ func (cm *CommandMapping) RegisterViewCommands() { })) cm.Define("enter-command", "Enter command", "", func(ctx *CommandContext) error { - ctx.Frame().Prompt(": ", func(res string) { - ctx.Frame().Message("Command = " + res) + ctx.Frame().Prompt(":", func(res string) { + cm.DoEval(ctx, res) }) return nil }) @@ -104,10 +122,38 @@ func (cm *CommandMapping) RegisterViewCommands() { return nil }) + cm.Define("save", "Save current file", "", func(ctx *CommandContext) error { + wSource, isWSource := ctx.Session().Source.(WritableModelSource) + if !isWSource { + return fmt.Errorf("model is not writable") + } + + if err := wSource.Write(ctx.Session().Model); err != nil { + return err + } + + ctx.Frame().Message("Wrote " + wSource.String()) + return nil + }) + cm.Define("quit", "Quit TED", "", func(ctx *CommandContext) error { ctx.Session().UIManager.Shutdown() return nil }) + + cm.Define("save-and-quit", "Save current file, then quit", "", func(ctx *CommandContext) error { + if err := cm.Eval(ctx, "save"); err != nil { + return nil + } + + return cm.Eval(ctx, "quit") + }) + + + // Aliases + cm.Commands["w"] = cm.Command("save") + cm.Commands["q"] = cm.Command("quit") + cm.Commands["wq"] = cm.Command("save-and-quit") } // Registers the standard view key bindings. These commands require the frame @@ -133,8 +179,6 @@ func (cm *CommandMapping) RegisterViewKeyBindings() { cm.MapKey('e', cm.Command("set-cell")) cm.MapKey(':', cm.Command("enter-command")) - - cm.MapKey('q', cm.Command("quit")) } // A nativation command factory. This will perform the passed in operation with the current grid and diff --git a/frame.go b/frame.go index da0321d..9d795e6 100644 --- a/frame.go +++ b/frame.go @@ -102,16 +102,20 @@ func (frame *Frame) Prompt(prompt string, callback func(res string)) { frame.textEntry.Prompt = prompt frame.textEntry.SetValue("") + frame.textEntry.OnCancel = frame.exitEntryMode frame.textEntry.OnEntry = func(res string) { - frame.textEntry.OnEntry = nil - frame.setMode(GridMode) - + frame.exitEntryMode() callback(res) } frame.setMode(EntryMode) } +func (frame *Frame) exitEntryMode() { + frame.textEntry.OnEntry = nil + frame.setMode(GridMode) +} + // Show a message. This will switch the bottom to the messageView and select the frame func (frame *Frame) ShowMessage(msg string) { frame.messageView.Text = msg diff --git a/main.go b/main.go index 4c09459..be00528 100644 --- a/main.go +++ b/main.go @@ -11,11 +11,9 @@ func main() { } defer uiManager.Close() - model := &StdModel{} - model.Resize(5, 5) - frame := NewFrame(uiManager) - NewSession(uiManager, frame, model) + session := NewSession(uiManager, frame, CsvFileModelSource{"test.csv"}) + session.LoadFromSource() uiManager.SetRootComponent(frame.RootComponent()) frame.enterMode(GridMode) diff --git a/model.go b/model.go index 9a9d48e..0969d78 100644 --- a/model.go +++ b/model.go @@ -3,20 +3,13 @@ */ package main - -/** - * An abstract model interface. At a minimum, models must be read only. - */ +// An abstract model interface. At a minimum, models must be read only. type Model interface { - /** - * The dimensions of the model (height, width). - */ + // The dimensions of the model (height, width). Dimensions() (int, int) - /** - * Returns the value of a cell. - */ + // Returns the value of a cell CellValue(r, c int) string } @@ -29,4 +22,7 @@ type RWModel interface { // Sets the cell value SetCellValue(r, c int, value string) + + // Returns true if the model has been modified in some way + IsDirty() bool } diff --git a/modelsource.go b/modelsource.go new file mode 100644 index 0000000..b512d34 --- /dev/null +++ b/modelsource.go @@ -0,0 +1,91 @@ +package main + +import ( + "path/filepath" + "os" + "encoding/csv" + "io" +) + +// ModelSource is a source of models. At a minimum, it must be able to read models. +type ModelSource interface { + // Describes the source + String() string + + // Read the model from the given source + Read() (Model, error) +} + +// Writable models take a model and write it to the source +type WritableModelSource interface { + ModelSource + + // Write writes a model to the source + Write(m Model) error +} + +// A model source backed by a CSV file +type CsvFileModelSource struct { + Filename string +} + +// Describes the source +func (s CsvFileModelSource) String() string { + return filepath.Base(s.Filename) +} + +// Read the model from the given source +func (s CsvFileModelSource) Read() (Model, error) { + f, err := os.Open(s.Filename) + if err != nil { + return nil, err + } + defer f.Close() + + model := new(StdModel) + r := csv.NewReader(f) + for { + record, err := r.Read() + if err == io.EOF { + break + } + if err != nil { + return nil, err + } + + model.appendStr(record) + } + + model.dirty = false + return model, nil +} + +func (s CsvFileModelSource) Write(m Model) error { + f, err := os.Create(s.Filename) + if err != nil { + return err + } + + w := csv.NewWriter(f) + + rows, cols := m.Dimensions() + + for r := 0; r < rows; r++ { + record := make([]string, cols) // Reuse the record slice + for c := 0; c < cols; c++ { + record[c] = m.CellValue(r, c) + } + if err := w.Write(record); err != nil { + f.Close() + return err + } + } + + w.Flush() + if err := w.Error(); err != nil { + f.Close() + return err + } + + return f.Close() +} \ No newline at end of file diff --git a/session.go b/session.go index 0fb33b9..5a11e30 100644 --- a/session.go +++ b/session.go @@ -6,14 +6,16 @@ import "bitbucket.org/lmika/ted-v2/ui" // the interaction between the two and the user. type Session struct { Model Model + Source ModelSource Frame *Frame Commands *CommandMapping UIManager *ui.Ui } -func NewSession(uiManager *ui.Ui, frame *Frame, model Model) *Session { +func NewSession(uiManager *ui.Ui, frame *Frame, source ModelSource) *Session { session := &Session{ - Model: model, + Model: nil, + Source: source, Frame: frame, Commands: NewCommandMapping(), UIManager: uiManager, @@ -30,6 +32,17 @@ func NewSession(uiManager *ui.Ui, frame *Frame, model Model) *Session { return session } +// LoadFromSource loads the model from the source, replacing the existing model +func (session *Session) LoadFromSource() error { + newModel, err := session.Source.Read() + if err != nil { + return err + } + + session.Model = newModel + return nil +} + // Input from the frame func (session *Session) KeyPressed(key rune, mod int) { // Add the mod key modifier @@ -59,6 +72,13 @@ func (scc *CommandContext) Frame() *Frame { return scc.session.Frame } +// Error displays an error if err is not nil +func (scc *CommandContext) ShowError(err error) { + if err != nil { + scc.Frame().Message(err.Error()) + } +} + // Session grid model type SessionGridModel struct { Session *Session diff --git a/stdmodel.go b/stdmodel.go index 3faacda..e5d87fd 100644 --- a/stdmodel.go +++ b/stdmodel.go @@ -2,57 +2,91 @@ package main // Cell type Cell struct { - Value string + Value string } // Standard model type StdModel struct { - Cells [][]Cell + Cells [][]Cell + dirty bool } /** * The dimensions of the model (height, width). */ func (sm *StdModel) Dimensions() (int, int) { - if len(sm.Cells) == 0 { - return 0, 0 - } else { - return len(sm.Cells), len(sm.Cells[0]) - } + if len(sm.Cells) == 0 { + return 0, 0 + } else { + return len(sm.Cells), len(sm.Cells[0]) + } } /** * Returns the value of a cell. */ func (sm *StdModel) CellValue(r, c int) string { - rs, cs := sm.Dimensions() - if (r >= 0) && (c >= 0) && (r < rs) && (c < cs) { - return sm.Cells[r][c].Value - } else { - return "" - } + rs, cs := sm.Dimensions() + if (r >= 0) && (c >= 0) && (r < rs) && (c < cs) { + return sm.Cells[r][c].Value + } else { + return "" + } } // Resize the model. func (sm *StdModel) Resize(rs, cs int) { - oldRowCount := len(sm.Cells) - - newRows := make([][]Cell, rs) - for r := range newRows { - newCols := make([]Cell, cs) - if r < oldRowCount { - copy(newCols, sm.Cells[r]) - } - newRows[r] = newCols - } + oldRowCount := len(sm.Cells) - sm.Cells = newRows + newRows := make([][]Cell, rs) + for r := range newRows { + newCols := make([]Cell, cs) + if r < oldRowCount { + copy(newCols, sm.Cells[r]) + } + newRows[r] = newCols + } + + sm.Cells = newRows + sm.dirty = true } // Sets the cell value func (sm *StdModel) SetCellValue(r, c int, value string) { - rs, cs := sm.Dimensions() - if (r >= 0) && (c >= 0) && (r < rs) && (c < cs) { - sm.Cells[r][c].Value = value - } -} \ No newline at end of file + rs, cs := sm.Dimensions() + if (r >= 0) && (c >= 0) && (r < rs) && (c < cs) { + sm.Cells[r][c].Value = value + } + sm.dirty = true +} + +// appendStr appends the model with the given row +func (sm *StdModel) appendStr(row []string) { + if len(sm.Cells) == 0 { + cells := sm.strSliceToCell(row, len(row)) + sm.Cells = [][]Cell{ cells } + return + } + + cols := len(sm.Cells[0]) + if len(row) > cols { + sm.Resize(len(sm.Cells), len(row)) + cols = len(sm.Cells[0]) + } + cells := sm.strSliceToCell(row, cols) + sm.Cells = append(sm.Cells, cells) +} + +func (sm *StdModel) strSliceToCell(row []string, targetRowLen int) []Cell { + cs := make([]Cell, targetRowLen) + for i, c := range row { + if i < targetRowLen { + cs[i].Value = c + } + } + return cs +} + +func (sm *StdModel) IsDirty() bool { + return sm.dirty +} diff --git a/ui/driver.go b/ui/driver.go index a43b860..9b642f7 100644 --- a/ui/driver.go +++ b/ui/driver.go @@ -87,6 +87,7 @@ const ( KeyBackspace = KeyCtrlH KeyBackspace2 = KeyCtrl8 KeyEnter = KeyCtrlM + KeyEsc = KeyCtrl3 ) // The type of events supported by the driver @@ -116,7 +117,6 @@ type Event struct { // The terminal driver interface. type Driver interface { - // Initializes the driver. Returns an error if there was an error Init() error diff --git a/ui/stdcomps.go b/ui/stdcomps.go index 77d420d..b25c784 100644 --- a/ui/stdcomps.go +++ b/ui/stdcomps.go @@ -2,7 +2,9 @@ package ui -import "unicode" +import ( + "unicode" + ) // A text component. This simply renders a text string. type TextView struct { @@ -58,6 +60,9 @@ type TextEntry struct { // Called when the user presses Enter OnEntry func(val string) + + // Called when the user presses Esc or CtrlC + OnCancel func() } func (te *TextEntry) Remeasure(w, h int) (int, int) { @@ -127,11 +132,16 @@ func (te *TextEntry) KeyPressed(key rune, mod int) { } else if key == KeyDelete { te.removeCharAtPos(te.cursorOffset) } else if key == KeyEnter { - //panic("Entered text: '" + te.value + "'") if te.OnEntry != nil { te.OnEntry(te.value) } + } else if key == KeyCtrlC { + if te.OnCancel != nil { + te.OnCancel() + } } + + //panic(fmt.Sprintf("Entered key: '%x', mod: '%x'", key, mod)) } // Backspace