Issue 18: Added a popup to modify table columns (#31)

Added a new popup to modify the columns of the table. With this new popup, the user can:

- Show and hide columns
- Move columns around
- Add new columns which are derived from the value of an expression
- Delete columns

Also got the overlay mechanisms working.
This commit is contained in:
Leon Mika 2022-10-04 22:23:48 +11:00 committed by GitHub
parent f373a3313a
commit 982d3a9ca7
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
36 changed files with 1050 additions and 166 deletions

View file

@ -22,6 +22,7 @@ import (
"github.com/lmika/audax/internal/dynamo-browse/ui" "github.com/lmika/audax/internal/dynamo-browse/ui"
"github.com/lmika/audax/internal/dynamo-browse/ui/keybindings" "github.com/lmika/audax/internal/dynamo-browse/ui/keybindings"
"github.com/lmika/audax/internal/dynamo-browse/ui/teamodels/styles" "github.com/lmika/audax/internal/dynamo-browse/ui/teamodels/styles"
bus "github.com/lmika/events"
"github.com/lmika/gopkgs/cli" "github.com/lmika/gopkgs/cli"
"log" "log"
"net" "net"
@ -72,6 +73,8 @@ func main() {
dynamoClient = dynamodb.NewFromConfig(cfg) dynamoClient = dynamodb.NewFromConfig(cfg)
} }
eventBus := bus.New()
uiStyles := styles.DefaultStyles uiStyles := styles.DefaultStyles
dynamoProvider := dynamo.NewProvider(dynamoClient) dynamoProvider := dynamo.NewProvider(dynamoClient)
resultSetSnapshotStore := workspacestore.NewResultSetSnapshotStore(ws) resultSetSnapshotStore := workspacestore.NewResultSetSnapshotStore(ws)
@ -93,8 +96,10 @@ func main() {
itemRendererService := itemrenderer.NewService(uiStyles.ItemView.FieldType, uiStyles.ItemView.MetaInfo) itemRendererService := itemrenderer.NewService(uiStyles.ItemView.FieldType, uiStyles.ItemView.MetaInfo)
state := controllers.NewState() state := controllers.NewState()
tableReadController := controllers.NewTableReadController(state, tableService, workspaceService, itemRendererService, *flagTable, true) tableReadController := controllers.NewTableReadController(state, tableService, workspaceService, itemRendererService, eventBus, *flagTable)
tableWriteController := controllers.NewTableWriteController(state, tableService, tableReadController, settingStore) tableWriteController := controllers.NewTableWriteController(state, tableService, tableReadController, settingStore)
columnsController := controllers.NewColumnsController(eventBus)
exportController := controllers.NewExportController(state, columnsController)
settingsController := controllers.NewSettingsController(settingStore) settingsController := controllers.NewSettingsController(settingStore)
keyBindings := keybindings.Default() keyBindings := keybindings.Default()
@ -106,6 +111,8 @@ func main() {
model := ui.NewModel( model := ui.NewModel(
tableReadController, tableReadController,
tableWriteController, tableWriteController,
columnsController,
exportController,
settingsController, settingsController,
itemRendererService, itemRendererService,
commandController, commandController,

View file

@ -16,6 +16,12 @@ func SetStatus(msg string) tea.Cmd {
} }
} }
func SetTeaMessage(event tea.Msg) tea.Cmd {
return func() tea.Msg {
return event
}
}
func PromptForInput(prompt string, onDone func(value string) tea.Msg) tea.Msg { func PromptForInput(prompt string, onDone func(value string) tea.Msg) tea.Msg {
return PromptForInputMsg{ return PromptForInputMsg{
Prompt: prompt, Prompt: prompt,

View file

@ -0,0 +1,108 @@
package controllers
import (
tea "github.com/charmbracelet/bubbletea"
"github.com/lmika/audax/internal/common/ui/events"
"github.com/lmika/audax/internal/dynamo-browse/models"
"github.com/lmika/audax/internal/dynamo-browse/models/columns"
"github.com/lmika/audax/internal/dynamo-browse/models/queryexpr"
bus "github.com/lmika/events"
)
type ColumnsController struct {
// State
colModel *columns.Columns
resultSet *models.ResultSet
}
func NewColumnsController(eventBus *bus.Bus) *ColumnsController {
cc := &ColumnsController{}
eventBus.On(newResultSetEvent, cc.onNewResultSet)
return cc
}
func (cc *ColumnsController) Columns() *columns.Columns {
return cc.colModel
}
func (cc *ColumnsController) ToggleVisible(idx int) tea.Msg {
cc.colModel.Columns[idx].Hidden = !cc.colModel.Columns[idx].Hidden
return ColumnsUpdated{}
}
func (cc *ColumnsController) ShiftColumnLeft(idx int) tea.Msg {
if idx == 0 {
return nil
}
col := cc.colModel.Columns[idx-1]
cc.colModel.Columns[idx-1], cc.colModel.Columns[idx] = cc.colModel.Columns[idx], col
return ColumnsUpdated{}
}
func (cc *ColumnsController) ShiftColumnRight(idx int) tea.Msg {
if idx >= len(cc.colModel.Columns)-1 {
return nil
}
col := cc.colModel.Columns[idx+1]
cc.colModel.Columns[idx+1], cc.colModel.Columns[idx] = cc.colModel.Columns[idx], col
return ColumnsUpdated{}
}
func (cc *ColumnsController) SetColumnsToResultSet() tea.Msg {
cc.colModel = columns.NewColumnsFromResultSet(cc.resultSet)
return ColumnsUpdated{}
}
func (cc *ColumnsController) onNewResultSet(rs *models.ResultSet, op resultSetUpdateOp) {
cc.resultSet = rs
if cc.colModel == nil || (op == resultSetUpdateInit || op == resultSetUpdateQuery) {
cc.colModel = columns.NewColumnsFromResultSet(rs)
}
}
func (cc *ColumnsController) AddColumn(afterIndex int) tea.Msg {
return events.PromptForInput("column expr: ", func(value string) tea.Msg {
colExpr, err := queryexpr.Parse(value)
if err != nil {
return events.Error(err)
}
newCol := columns.Column{
Name: colExpr.String(),
Evaluator: columns.ExprFieldValueEvaluator{Expr: colExpr},
}
if afterIndex >= len(cc.colModel.Columns)-1 {
cc.colModel.Columns = append(cc.colModel.Columns, newCol)
} else {
newCols := make([]columns.Column, 0, len(cc.colModel.Columns)+1)
newCols = append(newCols, cc.colModel.Columns[:afterIndex+1]...)
newCols = append(newCols, newCol)
newCols = append(newCols, cc.colModel.Columns[afterIndex+1:]...)
cc.colModel.Columns = newCols
}
return ColumnsUpdated{}
})
}
func (cc *ColumnsController) DeleteColumn(afterIndex int) tea.Msg {
if len(cc.colModel.Columns) == 0 {
return nil
}
newCols := make([]columns.Column, 0, len(cc.colModel.Columns)-1)
newCols = append(newCols, cc.colModel.Columns[:afterIndex]...)
newCols = append(newCols, cc.colModel.Columns[afterIndex+1:]...)
cc.colModel.Columns = newCols
return ColumnsUpdated{}
}

View file

@ -0,0 +1,5 @@
package controllers
const (
newResultSetEvent = "new_result_set"
)

View file

@ -13,6 +13,11 @@ type SetTableItemView struct {
type SettingsUpdated struct { type SettingsUpdated struct {
} }
type ColumnsUpdated struct {
}
type MoveLeftmostDisplayedColumnInTableViewBy int
type NewResultSet struct { type NewResultSet struct {
ResultSet *models.ResultSet ResultSet *models.ResultSet
currentFilter string currentFilter string
@ -59,3 +64,6 @@ type ResultSetUpdated struct {
func (rs ResultSetUpdated) StatusMessage() string { func (rs ResultSetUpdated) StatusMessage() string {
return rs.statusMessage return rs.statusMessage
} }
type ShowColumnOverlay struct{}
type HideColumnOverlay struct{}

View file

@ -0,0 +1,57 @@
package controllers
import (
"encoding/csv"
tea "github.com/charmbracelet/bubbletea"
"github.com/lmika/audax/internal/common/ui/events"
"github.com/lmika/audax/internal/dynamo-browse/models"
"github.com/pkg/errors"
"os"
)
type ExportController struct {
state *State
columns *ColumnsController
}
func NewExportController(state *State, columns *ColumnsController) *ExportController {
return &ExportController{state, columns}
}
func (c *ExportController) ExportCSV(filename string) tea.Msg {
resultSet := c.state.ResultSet()
if resultSet == nil {
return events.Error(errors.New("no result set"))
}
f, err := os.Create(filename)
if err != nil {
return events.Error(errors.Wrapf(err, "cannot export to '%v'", filename))
}
defer f.Close()
cw := csv.NewWriter(f)
defer cw.Flush()
columns := c.columns.Columns().VisibleColumns()
colNames := make([]string, len(columns))
for i, c := range columns {
colNames[i] = c.Name
}
if err := cw.Write(colNames); err != nil {
return events.Error(errors.Wrapf(err, "cannot export to '%v'", filename))
}
row := make([]string, len(columns))
for _, item := range resultSet.Items() {
for i, col := range columns {
row[i], _ = models.AttributeToString(col.Evaluator.EvaluateForItem(item))
}
if err := cw.Write(row); err != nil {
return events.Error(errors.Wrapf(err, "cannot export to '%v'", filename))
}
}
return nil
}

View file

@ -0,0 +1,88 @@
package controllers_test
import (
"github.com/stretchr/testify/assert"
"os"
"strings"
"testing"
)
func TestExportController_ExportCSV(t *testing.T) {
t.Run("should export result set to CSV file", func(t *testing.T) {
srv := newService(t, serviceConfig{tableName: "bravo-table"})
tempFile := tempFile(t)
invokeCommand(t, srv.readController.Init())
invokeCommand(t, srv.exportController.ExportCSV(tempFile))
bts, err := os.ReadFile(tempFile)
assert.NoError(t, err)
assert.Equal(t, string(bts), strings.Join([]string{
"pk,sk,alpha,beta,gamma\n",
"abc,222,This is another some value,1231,\n",
"bbb,131,,2468,foobar\n",
"foo,bar,This is some value,,\n",
}, ""))
})
t.Run("should return error if result set is not set", func(t *testing.T) {
srv := newService(t, serviceConfig{tableName: "non-existant-table"})
tempFile := tempFile(t)
invokeCommandExpectingError(t, srv.readController.Init())
invokeCommandExpectingError(t, srv.exportController.ExportCSV(tempFile))
})
t.Run("should honour new columns in CSV file", func(t *testing.T) {
srv := newService(t, serviceConfig{tableName: "alpha-table"})
tempFile := tempFile(t)
invokeCommand(t, srv.readController.Init())
invokeCommandWithPrompt(t, srv.columnsController.AddColumn(0), "address.no")
invokeCommand(t, srv.columnsController.ShiftColumnLeft(1))
invokeCommandWithPrompt(t, srv.columnsController.AddColumn(1), "address.street")
invokeCommand(t, srv.columnsController.ShiftColumnLeft(1))
invokeCommand(t, srv.exportController.ExportCSV(tempFile))
bts, err := os.ReadFile(tempFile)
assert.NoError(t, err)
assert.Equal(t, string(bts), strings.Join([]string{
"pk,address.no,address.street,sk,address,age,alpha,beta,gamma,useMailing\n",
"abc,123,Fake st.,111,,23,This is some value,,,true\n",
"abc,,,222,,,This is another some value,1231,,\n",
"bbb,,,131,,,,2468,foobar,\n",
}, ""))
})
t.Run("should honour hidden columns in CSV file", func(t *testing.T) {
srv := newService(t, serviceConfig{tableName: "alpha-table"})
tempFile := tempFile(t)
invokeCommand(t, srv.readController.Init())
invokeCommand(t, srv.columnsController.ToggleVisible(1))
invokeCommand(t, srv.columnsController.ToggleVisible(2))
invokeCommand(t, srv.exportController.ExportCSV(tempFile))
bts, err := os.ReadFile(tempFile)
assert.NoError(t, err)
assert.Equal(t, string(bts), strings.Join([]string{
"pk,age,alpha,beta,gamma,useMailing\n",
"abc,23,This is some value,,,true\n",
"abc,,This is another some value,1231,,\n",
"bbb,,,2468,foobar,\n",
}, ""))
})
// Hidden items?
}

View file

@ -2,7 +2,6 @@ package controllers
import ( import (
"context" "context"
"encoding/csv"
"fmt" "fmt"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
"github.com/lmika/audax/internal/common/ui/events" "github.com/lmika/audax/internal/common/ui/events"
@ -11,18 +10,30 @@ import (
"github.com/lmika/audax/internal/dynamo-browse/models/serialisable" "github.com/lmika/audax/internal/dynamo-browse/models/serialisable"
"github.com/lmika/audax/internal/dynamo-browse/services/itemrenderer" "github.com/lmika/audax/internal/dynamo-browse/services/itemrenderer"
"github.com/lmika/audax/internal/dynamo-browse/services/workspaces" "github.com/lmika/audax/internal/dynamo-browse/services/workspaces"
bus "github.com/lmika/events"
"github.com/pkg/errors" "github.com/pkg/errors"
"golang.design/x/clipboard" "golang.design/x/clipboard"
"log" "log"
"os"
"strings" "strings"
"sync" "sync"
) )
type resultSetUpdateOp int
const (
resultSetUpdateInit resultSetUpdateOp = iota
resultSetUpdateQuery
resultSetUpdateFilter
resultSetUpdateSnapshotRestore
resultSetUpdateRescan
resultSetUpdateTouch
)
type TableReadController struct { type TableReadController struct {
tableService TableReadService tableService TableReadService
workspaceService *workspaces.ViewSnapshotService workspaceService *workspaces.ViewSnapshotService
itemRendererService *itemrenderer.Service itemRendererService *itemrenderer.Service
eventBus *bus.Bus
tableName string tableName string
loadFromLastView bool loadFromLastView bool
@ -37,14 +48,15 @@ func NewTableReadController(
tableService TableReadService, tableService TableReadService,
workspaceService *workspaces.ViewSnapshotService, workspaceService *workspaces.ViewSnapshotService,
itemRendererService *itemrenderer.Service, itemRendererService *itemrenderer.Service,
eventBus *bus.Bus,
tableName string, tableName string,
loadFromLastView bool,
) *TableReadController { ) *TableReadController {
return &TableReadController{ return &TableReadController{
state: state, state: state,
tableService: tableService, tableService: tableService,
workspaceService: workspaceService, workspaceService: workspaceService,
itemRendererService: itemRendererService, itemRendererService: itemRendererService,
eventBus: eventBus,
tableName: tableName, tableName: tableName,
mutex: new(sync.Mutex), mutex: new(sync.Mutex),
} }
@ -94,7 +106,7 @@ func (c *TableReadController) ScanTable(name string) tea.Msg {
} }
resultSet = c.tableService.Filter(resultSet, c.state.Filter()) resultSet = c.tableService.Filter(resultSet, c.state.Filter())
return c.setResultSetAndFilter(resultSet, c.state.Filter(), true) return c.setResultSetAndFilter(resultSet, c.state.Filter(), true, resultSetUpdateInit)
} }
func (c *TableReadController) PromptForQuery() tea.Msg { func (c *TableReadController) PromptForQuery() tea.Msg {
@ -117,7 +129,7 @@ func (c *TableReadController) runQuery(tableInfo *models.TableInfo, query, newFi
newResultSet = c.tableService.Filter(newResultSet, newFilter) newResultSet = c.tableService.Filter(newResultSet, newFilter)
} }
return c.setResultSetAndFilter(newResultSet, newFilter, pushSnapshot) return c.setResultSetAndFilter(newResultSet, newFilter, pushSnapshot, resultSetUpdateQuery)
} }
expr, err := queryexpr.Parse(query) expr, err := queryexpr.Parse(query)
@ -134,7 +146,7 @@ func (c *TableReadController) runQuery(tableInfo *models.TableInfo, query, newFi
if newFilter != "" { if newFilter != "" {
newResultSet = c.tableService.Filter(newResultSet, newFilter) newResultSet = c.tableService.Filter(newResultSet, newFilter)
} }
return c.setResultSetAndFilter(newResultSet, newFilter, pushSnapshot) return c.setResultSetAndFilter(newResultSet, newFilter, pushSnapshot, resultSetUpdateQuery)
}) })
} }
@ -163,44 +175,11 @@ func (c *TableReadController) doIfNoneDirty(cmd tea.Cmd) tea.Msg {
func (c *TableReadController) Rescan() tea.Msg { func (c *TableReadController) Rescan() tea.Msg {
return c.doIfNoneDirty(func() tea.Msg { return c.doIfNoneDirty(func() tea.Msg {
resultSet := c.state.ResultSet() resultSet := c.state.ResultSet()
return c.doScan(context.Background(), resultSet, resultSet.Query, true) return c.doScan(context.Background(), resultSet, resultSet.Query, true, resultSetUpdateRescan)
}) })
} }
func (c *TableReadController) ExportCSV(filename string) tea.Msg { func (c *TableReadController) doScan(ctx context.Context, resultSet *models.ResultSet, query models.Queryable, pushBackstack bool, op resultSetUpdateOp) tea.Msg {
resultSet := c.state.ResultSet()
if resultSet == nil {
return events.Error(errors.New("no result set"))
}
f, err := os.Create(filename)
if err != nil {
return events.Error(errors.Wrapf(err, "cannot export to '%v'", filename))
}
defer f.Close()
cw := csv.NewWriter(f)
defer cw.Flush()
columns := resultSet.Columns()
if err := cw.Write(columns); err != nil {
return events.Error(errors.Wrapf(err, "cannot export to '%v'", filename))
}
row := make([]string, len(columns))
for _, item := range resultSet.Items() {
for i, col := range columns {
row[i], _ = item.AttributeValueAsString(col)
}
if err := cw.Write(row); err != nil {
return events.Error(errors.Wrapf(err, "cannot export to '%v'", filename))
}
}
return nil
}
func (c *TableReadController) doScan(ctx context.Context, resultSet *models.ResultSet, query models.Queryable, pushBackstack bool) tea.Msg {
newResultSet, err := c.tableService.ScanOrQuery(ctx, resultSet.TableInfo, query) newResultSet, err := c.tableService.ScanOrQuery(ctx, resultSet.TableInfo, query)
if err != nil { if err != nil {
return events.Error(err) return events.Error(err)
@ -208,10 +187,10 @@ func (c *TableReadController) doScan(ctx context.Context, resultSet *models.Resu
newResultSet = c.tableService.Filter(newResultSet, c.state.Filter()) newResultSet = c.tableService.Filter(newResultSet, c.state.Filter())
return c.setResultSetAndFilter(newResultSet, c.state.Filter(), pushBackstack) return c.setResultSetAndFilter(newResultSet, c.state.Filter(), pushBackstack, op)
} }
func (c *TableReadController) setResultSetAndFilter(resultSet *models.ResultSet, filter string, pushBackstack bool) tea.Msg { func (c *TableReadController) setResultSetAndFilter(resultSet *models.ResultSet, filter string, pushBackstack bool, op resultSetUpdateOp) tea.Msg {
if pushBackstack { if pushBackstack {
if err := c.workspaceService.PushSnapshot(resultSet, filter); err != nil { if err := c.workspaceService.PushSnapshot(resultSet, filter); err != nil {
log.Printf("cannot push snapshot: %v", err) log.Printf("cannot push snapshot: %v", err)
@ -219,6 +198,9 @@ func (c *TableReadController) setResultSetAndFilter(resultSet *models.ResultSet,
} }
c.state.setResultSetAndFilter(resultSet, filter) c.state.setResultSetAndFilter(resultSet, filter)
c.eventBus.Fire(newResultSetEvent, resultSet, op)
return c.state.buildNewResultSetMessage("") return c.state.buildNewResultSetMessage("")
} }
@ -238,7 +220,7 @@ func (c *TableReadController) Filter() tea.Msg {
resultSet := c.state.ResultSet() resultSet := c.state.ResultSet()
newResultSet := c.tableService.Filter(resultSet, value) newResultSet := c.tableService.Filter(resultSet, value)
return c.setResultSetAndFilter(newResultSet, value, true) return c.setResultSetAndFilter(newResultSet, value, true, resultSetUpdateFilter)
}, },
} }
} }
@ -286,7 +268,7 @@ func (c *TableReadController) updateViewToSnapshot(viewSnapshot *serialisable.Vi
log.Printf("backstack: setting filter to '%v'", viewSnapshot.Filter) log.Printf("backstack: setting filter to '%v'", viewSnapshot.Filter)
newResultSet := c.tableService.Filter(currentResultSet, viewSnapshot.Filter) newResultSet := c.tableService.Filter(currentResultSet, viewSnapshot.Filter)
return c.setResultSetAndFilter(newResultSet, viewSnapshot.Filter, false) return c.setResultSetAndFilter(newResultSet, viewSnapshot.Filter, false, resultSetUpdateSnapshotRestore)
} }
tableInfo := currentResultSet.TableInfo tableInfo := currentResultSet.TableInfo

View file

@ -81,38 +81,6 @@ func TestTableReadController_Rescan(t *testing.T) {
}) })
} }
func TestTableReadController_ExportCSV(t *testing.T) {
t.Run("should export result set to CSV file", func(t *testing.T) {
srv := newService(t, serviceConfig{tableName: "bravo-table"})
tempFile := tempFile(t)
invokeCommand(t, srv.readController.Init())
invokeCommand(t, srv.readController.ExportCSV(tempFile))
bts, err := os.ReadFile(tempFile)
assert.NoError(t, err)
assert.Equal(t, string(bts), strings.Join([]string{
"pk,sk,alpha,beta,gamma\n",
"abc,222,This is another some value,1231,\n",
"bbb,131,,2468,foobar\n",
"foo,bar,This is some value,,\n",
}, ""))
})
t.Run("should return error if result set is not set", func(t *testing.T) {
srv := newService(t, serviceConfig{tableName: "non-existant-table"})
tempFile := tempFile(t)
invokeCommandExpectingError(t, srv.readController.Init())
invokeCommandExpectingError(t, srv.readController.ExportCSV(tempFile))
})
// Hidden items?
}
func TestTableReadController_Query(t *testing.T) { func TestTableReadController_Query(t *testing.T) {
t.Run("should run scan with filter based on user query", func(t *testing.T) { t.Run("should run scan with filter based on user query", func(t *testing.T) {
srv := newService(t, serviceConfig{tableName: "bravo-table"}) srv := newService(t, serviceConfig{tableName: "bravo-table"})
@ -121,7 +89,7 @@ func TestTableReadController_Query(t *testing.T) {
invokeCommand(t, srv.readController.Init()) invokeCommand(t, srv.readController.Init())
invokeCommandWithPrompts(t, srv.readController.PromptForQuery(), `pk ^= "abc"`) invokeCommandWithPrompts(t, srv.readController.PromptForQuery(), `pk ^= "abc"`)
invokeCommand(t, srv.readController.ExportCSV(tempFile)) invokeCommand(t, srv.exportController.ExportCSV(tempFile))
bts, err := os.ReadFile(tempFile) bts, err := os.ReadFile(tempFile)
assert.NoError(t, err) assert.NoError(t, err)
@ -138,7 +106,7 @@ func TestTableReadController_Query(t *testing.T) {
tempFile := tempFile(t) tempFile := tempFile(t)
invokeCommandExpectingError(t, srv.readController.Init()) invokeCommandExpectingError(t, srv.readController.Init())
invokeCommandExpectingError(t, srv.readController.ExportCSV(tempFile)) invokeCommandExpectingError(t, srv.exportController.ExportCSV(tempFile))
}) })
} }

View file

@ -375,7 +375,7 @@ func (twc *TableWriteController) NoisyTouchItem(idx int) tea.Msg {
return events.Error(err) return events.Error(err)
} }
return twc.tableReadControllers.doScan(ctx, resultSet, resultSet.Query, false) return twc.tableReadControllers.doScan(ctx, resultSet, resultSet.Query, false, resultSetUpdateTouch)
}, },
} }
} }
@ -406,7 +406,7 @@ func (twc *TableWriteController) DeleteMarked() tea.Msg {
return events.Error(err) return events.Error(err)
} }
return twc.tableReadControllers.doScan(ctx, resultSet, resultSet.Query, false) return twc.tableReadControllers.doScan(ctx, resultSet, resultSet.Query, false, resultSetUpdateTouch)
}, },
} }
} }

View file

@ -12,6 +12,7 @@ import (
"github.com/lmika/audax/internal/dynamo-browse/services/tables" "github.com/lmika/audax/internal/dynamo-browse/services/tables"
workspaces_service "github.com/lmika/audax/internal/dynamo-browse/services/workspaces" workspaces_service "github.com/lmika/audax/internal/dynamo-browse/services/workspaces"
"github.com/lmika/audax/test/testdynamo" "github.com/lmika/audax/test/testdynamo"
bus "github.com/lmika/events"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"testing" "testing"
) )
@ -571,6 +572,8 @@ type services struct {
readController *controllers.TableReadController readController *controllers.TableReadController
writeController *controllers.TableWriteController writeController *controllers.TableWriteController
settingsController *controllers.SettingsController settingsController *controllers.SettingsController
columnsController *controllers.ColumnsController
exportController *controllers.ExportController
} }
type serviceConfig struct { type serviceConfig struct {
@ -590,11 +593,14 @@ func newService(t *testing.T, cfg serviceConfig) *services {
provider := dynamo.NewProvider(client) provider := dynamo.NewProvider(client)
service := tables.NewService(provider, settingStore) service := tables.NewService(provider, settingStore)
eventBus := bus.New()
state := controllers.NewState() state := controllers.NewState()
readController := controllers.NewTableReadController(state, service, workspaceService, itemRendererService, cfg.tableName, false) readController := controllers.NewTableReadController(state, service, workspaceService, itemRendererService, eventBus, cfg.tableName)
writeController := controllers.NewTableWriteController(state, service, readController, settingStore) writeController := controllers.NewTableWriteController(state, service, readController, settingStore)
settingsController := controllers.NewSettingsController(settingStore) settingsController := controllers.NewSettingsController(settingStore)
columnsController := controllers.NewColumnsController(eventBus)
exportController := controllers.NewExportController(state, columnsController)
if cfg.isReadOnly { if cfg.isReadOnly {
if err := settingStore.SetReadOnly(cfg.isReadOnly); err != nil { if err := settingStore.SetReadOnly(cfg.isReadOnly); err != nil {
@ -608,5 +614,7 @@ func newService(t *testing.T, cfg serviceConfig) *services {
readController: readController, readController: readController,
writeController: writeController, writeController: writeController,
settingsController: settingsController, settingsController: settingsController,
columnsController: columnsController,
exportController: exportController,
} }
} }

View file

@ -34,7 +34,7 @@ func compareScalarAttributes(x, y types.AttributeValue) (int, bool) {
return 0, false return 0, false
} }
func attributeToString(x types.AttributeValue) (string, bool) { func AttributeToString(x types.AttributeValue) (string, bool) {
switch xVal := x.(type) { switch xVal := x.(type) {
case *types.AttributeValueMemberS: case *types.AttributeValueMemberS:
return xVal.Value, true return xVal.Value, true

View file

@ -0,0 +1,69 @@
package columns
import (
"github.com/aws/aws-sdk-go-v2/service/dynamodb/types"
"github.com/lmika/audax/internal/dynamo-browse/models"
"github.com/lmika/audax/internal/dynamo-browse/models/queryexpr"
)
type Columns struct {
TableInfo *models.TableInfo
Columns []Column
}
func NewColumnsFromResultSet(rs *models.ResultSet) *Columns {
rsCols := rs.Columns()
cols := make([]Column, len(rsCols))
for i, c := range rsCols {
cols[i] = Column{
Name: c,
Evaluator: SimpleFieldValueEvaluator(c),
}
}
return &Columns{
TableInfo: rs.TableInfo,
Columns: cols,
}
}
func (cols *Columns) VisibleColumns() []Column {
if cols == nil {
return []Column{}
}
visibleCols := make([]Column, 0)
for _, col := range cols.Columns {
if col.Hidden {
continue
}
visibleCols = append(visibleCols, col)
}
return visibleCols
}
type Column struct {
Name string
Evaluator FieldValueEvaluator
Hidden bool
}
type FieldValueEvaluator interface {
EvaluateForItem(item models.Item) types.AttributeValue
}
type SimpleFieldValueEvaluator string
func (sfve SimpleFieldValueEvaluator) EvaluateForItem(item models.Item) types.AttributeValue {
return item[string(sfve)]
}
type ExprFieldValueEvaluator struct {
Expr *queryexpr.QueryExpr
}
func (sfve ExprFieldValueEvaluator) EvaluateForItem(item models.Item) types.AttributeValue {
val, _ := sfve.Expr.EvalItem(item)
return val
}

View file

@ -2,7 +2,6 @@ package models
import ( import (
"github.com/aws/aws-sdk-go-v2/service/dynamodb/types" "github.com/aws/aws-sdk-go-v2/service/dynamodb/types"
"github.com/lmika/audax/internal/dynamo-browse/models/itemrender"
) )
type ItemIndex struct { type ItemIndex struct {
@ -34,9 +33,5 @@ func (i Item) KeyValue(info *TableInfo) map[string]types.AttributeValue {
} }
func (i Item) AttributeValueAsString(key string) (string, bool) { func (i Item) AttributeValueAsString(key string) (string, bool) {
return attributeToString(i[key]) return AttributeToString(i[key])
}
func (i Item) Renderer(key string) itemrender.Renderer {
return itemrender.ToRenderer(i[key])
} }

View file

@ -2,6 +2,7 @@ package queryexpr
import ( import (
"github.com/alecthomas/participle/v2" "github.com/alecthomas/participle/v2"
"github.com/aws/aws-sdk-go-v2/service/dynamodb/types"
"github.com/lmika/audax/internal/dynamo-browse/models" "github.com/lmika/audax/internal/dynamo-browse/models"
"github.com/pkg/errors" "github.com/pkg/errors"
) )
@ -17,6 +18,10 @@ func (a *astExpr) evalToIR(tableInfo *models.TableInfo) (*irDisjunction, error)
return a.Root.evalToIR(tableInfo) return a.Root.evalToIR(tableInfo)
} }
func (a *astExpr) evalItem(item models.Item) (types.AttributeValue, error) {
return a.Root.evalItem(item)
}
type astDisjunction struct { type astDisjunction struct {
Operands []*astConjunction `parser:"@@ ('or' @@)*"` Operands []*astConjunction `parser:"@@ ('or' @@)*"`
} }
@ -25,10 +30,16 @@ type astConjunction struct {
Operands []*astBinOp `parser:"@@ ('and' @@)*"` Operands []*astBinOp `parser:"@@ ('and' @@)*"`
} }
// TODO: do this properly
type astBinOp struct { type astBinOp struct {
Ref *astDot `parser:"@@"`
Op string `parser:"( @('^' '=' | '=')"`
Value *astLiteralValue `parser:"@@ )?"`
}
type astDot struct {
Name string `parser:"@Ident"` Name string `parser:"@Ident"`
Op string `parser:"@('^' '=' | '=')"` Quals []string `parser:"('.' @Ident)*"`
Value *astLiteralValue `parser:"@@"`
} }
type astLiteralValue struct { type astLiteralValue struct {

View file

@ -2,6 +2,7 @@ package queryexpr
import ( import (
"github.com/aws/aws-sdk-go-v2/feature/dynamodb/expression" "github.com/aws/aws-sdk-go-v2/feature/dynamodb/expression"
"github.com/aws/aws-sdk-go-v2/service/dynamodb/types"
"github.com/lmika/audax/internal/dynamo-browse/models" "github.com/lmika/audax/internal/dynamo-browse/models"
"github.com/pkg/errors" "github.com/pkg/errors"
) )
@ -12,22 +13,40 @@ func (a *astBinOp) evalToIR(info *models.TableInfo) (irAtom, error) {
return nil, err return nil, err
} }
singleName, isSingleName := a.Ref.unqualifiedName()
if !isSingleName {
return nil, errors.Errorf("%v: cannot use dereferences", singleName)
}
switch a.Op { switch a.Op {
case "=": case "=":
return irFieldEq{name: a.Name, value: v}, nil return irFieldEq{name: singleName, value: v}, nil
case "^=": case "^=":
strValue, isStrValue := v.(string) strValue, isStrValue := v.(string)
if !isStrValue { if !isStrValue {
return nil, errors.New("operand '^=' must be string") return nil, errors.New("operand '^=' must be string")
} }
return irFieldBeginsWith{name: a.Name, prefix: strValue}, nil return irFieldBeginsWith{name: singleName, prefix: strValue}, nil
} }
return nil, errors.Errorf("unrecognised operator: %v", a.Op) return nil, errors.Errorf("unrecognised operator: %v", a.Op)
} }
func (a *astBinOp) evalItem(item models.Item) (types.AttributeValue, error) {
left, err := a.Ref.evalItem(item)
if err != nil {
return nil, err
}
if a.Op == "" {
return left, nil
}
return nil, errors.New("TODO")
}
func (a *astBinOp) String() string { func (a *astBinOp) String() string {
return a.Name + a.Op + a.Value.String() return a.Ref.String() + a.Op + a.Value.String()
} }
type irFieldEq struct { type irFieldEq struct {

View file

@ -2,6 +2,7 @@ package queryexpr
import ( import (
"github.com/aws/aws-sdk-go-v2/feature/dynamodb/expression" "github.com/aws/aws-sdk-go-v2/feature/dynamodb/expression"
"github.com/aws/aws-sdk-go-v2/service/dynamodb/types"
"github.com/lmika/audax/internal/dynamo-browse/models" "github.com/lmika/audax/internal/dynamo-browse/models"
"github.com/pkg/errors" "github.com/pkg/errors"
"strings" "strings"
@ -20,6 +21,14 @@ func (a *astConjunction) evalToIR(tableInfo *models.TableInfo) (*irConjunction,
return &irConjunction{atoms: atoms}, nil return &irConjunction{atoms: atoms}, nil
} }
func (a *astConjunction) evalItem(item models.Item) (types.AttributeValue, error) {
if len(a.Operands) == 1 {
return a.Operands[0].evalItem(item)
}
return nil, errors.New("TODO")
}
func (d *astConjunction) String() string { func (d *astConjunction) String() string {
sb := new(strings.Builder) sb := new(strings.Builder)
for i, operand := range d.Operands { for i, operand := range d.Operands {

View file

@ -2,6 +2,7 @@ package queryexpr
import ( import (
"github.com/aws/aws-sdk-go-v2/feature/dynamodb/expression" "github.com/aws/aws-sdk-go-v2/feature/dynamodb/expression"
"github.com/aws/aws-sdk-go-v2/service/dynamodb/types"
"github.com/lmika/audax/internal/dynamo-browse/models" "github.com/lmika/audax/internal/dynamo-browse/models"
"github.com/pkg/errors" "github.com/pkg/errors"
"strings" "strings"
@ -20,6 +21,14 @@ func (a *astDisjunction) evalToIR(tableInfo *models.TableInfo) (*irDisjunction,
return &irDisjunction{conj: conj}, nil return &irDisjunction{conj: conj}, nil
} }
func (a *astDisjunction) evalItem(item models.Item) (types.AttributeValue, error) {
if len(a.Operands) == 1 {
return a.Operands[0].evalItem(item)
}
return nil, errors.New("TODO")
}
func (d *astDisjunction) String() string { func (d *astDisjunction) String() string {
sb := new(strings.Builder) sb := new(strings.Builder)
for i, operand := range d.Operands { for i, operand := range d.Operands {

View file

@ -0,0 +1,47 @@
package queryexpr
import (
"github.com/aws/aws-sdk-go-v2/service/dynamodb/types"
"github.com/lmika/audax/internal/dynamo-browse/models"
"strings"
)
func (dt *astDot) unqualifiedName() (string, bool) {
if len(dt.Quals) == 0 {
return dt.Name, true
}
return "", false
}
func (dt *astDot) evalItem(item models.Item) (types.AttributeValue, error) {
res, hasV := item[dt.Name]
if !hasV {
return nil, NameNotFoundError(dt.String())
}
for i, qualName := range dt.Quals {
mapRes, isMapRes := res.(*types.AttributeValueMemberM)
if !isMapRes {
return nil, ValueNotAMapError(append([]string{dt.Name}, dt.Quals[:i+1]...))
}
res, hasV = mapRes.Value[qualName]
if !hasV {
return nil, NameNotFoundError(dt.String())
}
}
return res, nil
}
func (a *astDot) String() string {
var sb strings.Builder
sb.WriteString(a.Name)
for _, q := range a.Quals {
sb.WriteRune('.')
sb.WriteString(q)
}
return sb.String()
}

View file

@ -0,0 +1,20 @@
package queryexpr
import (
"fmt"
"strings"
)
// NameNotFoundError is returned if the given name cannot be found
type NameNotFoundError string
func (n NameNotFoundError) Error() string {
return fmt.Sprintf("%v: name not found", string(n))
}
// ValueNotAMapError is return if the given name is not a map
type ValueNotAMapError []string
func (n ValueNotAMapError) Error() string {
return fmt.Sprintf("%v: name is not a map", strings.Join(n, "."))
}

View file

@ -1,6 +1,9 @@
package queryexpr package queryexpr
import "github.com/lmika/audax/internal/dynamo-browse/models" import (
"github.com/aws/aws-sdk-go-v2/service/dynamodb/types"
"github.com/lmika/audax/internal/dynamo-browse/models"
)
type QueryExpr struct { type QueryExpr struct {
ast *astExpr ast *astExpr
@ -15,6 +18,10 @@ func (md *QueryExpr) Plan(tableInfo *models.TableInfo) (*models.QueryExecutionPl
return ir.calcQuery(tableInfo) return ir.calcQuery(tableInfo)
} }
func (md *QueryExpr) EvalItem(item models.Item) (types.AttributeValue, error) {
return md.ast.evalItem(item)
}
func (md *QueryExpr) String() string { func (md *QueryExpr) String() string {
return md.ast.String() return md.ast.String()
} }

View file

@ -132,3 +132,67 @@ func TestModExpr_Query(t *testing.T) {
}) })
}) })
} }
func TestQueryExpr_EvalItem(t *testing.T) {
var (
item = models.Item{
"alpha": &types.AttributeValueMemberS{Value: "alpha"},
"bravo": &types.AttributeValueMemberN{Value: "123"},
"charlie": &types.AttributeValueMemberM{
Value: map[string]types.AttributeValue{
"door": &types.AttributeValueMemberS{Value: "red"},
"tree": &types.AttributeValueMemberS{Value: "green"},
},
},
}
)
t.Run("simple values", func(t *testing.T) {
scenarios := []struct {
expr string
expected types.AttributeValue
}{
// Simple values
{expr: `alpha`, expected: &types.AttributeValueMemberS{Value: "alpha"}},
{expr: `bravo`, expected: &types.AttributeValueMemberN{Value: "123"}},
{expr: `charlie`, expected: item["charlie"]},
// Dot values
{expr: `charlie.door`, expected: &types.AttributeValueMemberS{Value: "red"}},
{expr: `charlie.tree`, expected: &types.AttributeValueMemberS{Value: "green"}},
}
for _, scenario := range scenarios {
t.Run(scenario.expr, func(t *testing.T) {
modExpr, err := queryexpr.Parse(scenario.expr)
assert.NoError(t, err)
res, err := modExpr.EvalItem(item)
assert.NoError(t, err)
assert.Equal(t, scenario.expected, res)
})
}
})
t.Run("expression errors", func(t *testing.T) {
scenarios := []struct {
expr string
expectedError error
}{
{expr: `not_present`, expectedError: queryexpr.NameNotFoundError("not_present")},
{expr: `alpha.bravo`, expectedError: queryexpr.ValueNotAMapError([]string{"alpha", "bravo"})},
{expr: `charlie.tree.bla`, expectedError: queryexpr.ValueNotAMapError([]string{"charlie", "tree", "bla"})},
}
for _, scenario := range scenarios {
t.Run(scenario.expr, func(t *testing.T) {
modExpr, err := queryexpr.Parse(scenario.expr)
assert.NoError(t, err)
res, err := modExpr.EvalItem(item)
assert.Nil(t, res)
assert.Equal(t, scenario.expectedError, err)
})
}
})
}

View file

@ -8,6 +8,10 @@ import (
) )
func (a *astLiteralValue) dynamoValue() (types.AttributeValue, error) { func (a *astLiteralValue) dynamoValue() (types.AttributeValue, error) {
if a == nil {
return nil, nil
}
s, err := strconv.Unquote(a.StringVal) s, err := strconv.Unquote(a.StringVal)
if err != nil { if err != nil {
return nil, errors.Wrap(err, "cannot unquote string") return nil, errors.Wrap(err, "cannot unquote string")
@ -16,6 +20,10 @@ func (a *astLiteralValue) dynamoValue() (types.AttributeValue, error) {
} }
func (a *astLiteralValue) goValue() (any, error) { func (a *astLiteralValue) goValue() (any, error) {
if a == nil {
return nil, nil
}
s, err := strconv.Unquote(a.StringVal) s, err := strconv.Unquote(a.StringVal)
if err != nil { if err != nil {
return nil, errors.Wrap(err, "cannot unquote string") return nil, errors.Wrap(err, "cannot unquote string")
@ -24,5 +32,8 @@ func (a *astLiteralValue) goValue() (any, error) {
} }
func (a *astLiteralValue) String() string { func (a *astLiteralValue) String() string {
if a == nil {
return ""
}
return a.StringVal return a.StringVal
} }

View file

@ -6,6 +6,13 @@ type TableInfo struct {
DefinedAttributes []string DefinedAttributes []string
} }
func (ti *TableInfo) Equal(other *TableInfo) bool {
return ti.Name == other.Name &&
ti.Keys.PartitionKey == other.Keys.PartitionKey &&
ti.Keys.SortKey == other.Keys.SortKey &&
len(ti.DefinedAttributes) == len(other.DefinedAttributes) // Probably should be all
}
type KeyAttribute struct { type KeyAttribute struct {
PartitionKey string PartitionKey string
SortKey string SortKey string

View file

@ -32,13 +32,13 @@ func (s *Service) RenderItem(w io.Writer, item models.Item, resultSet *models.Re
seenColumns := make(map[string]struct{}) seenColumns := make(map[string]struct{})
for _, colName := range resultSet.Columns() { for _, colName := range resultSet.Columns() {
seenColumns[colName] = struct{}{} seenColumns[colName] = struct{}{}
if r := item.Renderer(colName); r != nil { if r := itemrender.ToRenderer(item[colName]); r != nil {
s.renderItem(tabWriter, "", colName, r, styles) s.renderItem(tabWriter, "", colName, r, styles)
} }
} }
for k, _ := range item { for k, _ := range item {
if _, seen := seenColumns[k]; !seen { if _, seen := seenColumns[k]; !seen {
if r := item.Renderer(k); r != nil { if r := itemrender.ToRenderer(item[k]); r != nil {
s.renderItem(tabWriter, "", k, r, styles) s.renderItem(tabWriter, "", k, r, styles)
} }
} }

View file

@ -4,6 +4,15 @@ import "github.com/charmbracelet/bubbles/key"
func Default() *KeyBindings { func Default() *KeyBindings {
return &KeyBindings{ return &KeyBindings{
ColumnPopup: &FieldsPopupBinding{
Close: key.NewBinding(key.WithKeys("ctrl+c", "esc"), key.WithHelp("ctrl+c/esc", "close popup")),
ShiftColumnLeft: key.NewBinding(key.WithKeys("I", "shift column left")),
ShiftColumnRight: key.NewBinding(key.WithKeys("K", "shift column right")),
ToggleVisible: key.NewBinding(key.WithKeys(" ", "toggle column visible")),
ResetColumns: key.NewBinding(key.WithKeys("R", "reset columns")),
AddColumn: key.NewBinding(key.WithKeys("a", "add new column")),
DeleteColumn: key.NewBinding(key.WithKeys("d", "delete column")),
},
TableView: &TableKeyBinding{ TableView: &TableKeyBinding{
MoveUp: key.NewBinding(key.WithKeys("i", "up")), MoveUp: key.NewBinding(key.WithKeys("i", "up")),
MoveDown: key.NewBinding(key.WithKeys("k", "down")), MoveDown: key.NewBinding(key.WithKeys("k", "down")),
@ -25,6 +34,7 @@ func Default() *KeyBindings {
CycleLayoutForward: key.NewBinding(key.WithKeys("w"), key.WithHelp("w", "cycle layout forward")), CycleLayoutForward: key.NewBinding(key.WithKeys("w"), key.WithHelp("w", "cycle layout forward")),
CycleLayoutBackwards: key.NewBinding(key.WithKeys("W"), key.WithHelp("W", "cycle layout backward")), CycleLayoutBackwards: key.NewBinding(key.WithKeys("W"), key.WithHelp("W", "cycle layout backward")),
PromptForCommand: key.NewBinding(key.WithKeys(":"), key.WithHelp(":", "prompt for command")), PromptForCommand: key.NewBinding(key.WithKeys(":"), key.WithHelp(":", "prompt for command")),
ShowColumnOverlay: key.NewBinding(key.WithKeys("f"), key.WithHelp("f", "show column overlay")),
Quit: key.NewBinding(key.WithKeys("ctrl+c", "esc"), key.WithHelp("ctrl+c/esc", "quit")), Quit: key.NewBinding(key.WithKeys("ctrl+c", "esc"), key.WithHelp("ctrl+c/esc", "quit")),
}, },
} }

View file

@ -3,10 +3,21 @@ package keybindings
import "github.com/charmbracelet/bubbles/key" import "github.com/charmbracelet/bubbles/key"
type KeyBindings struct { type KeyBindings struct {
ColumnPopup *FieldsPopupBinding `keymap:"column-popup"`
TableView *TableKeyBinding `keymap:"item-table"` TableView *TableKeyBinding `keymap:"item-table"`
View *ViewKeyBindings `keymap:"view"` View *ViewKeyBindings `keymap:"view"`
} }
type FieldsPopupBinding struct {
Close key.Binding `keymap:"close"`
ShiftColumnLeft key.Binding `keymap:"shift-column-left"`
ShiftColumnRight key.Binding `keymap:"shift-column-right"`
ToggleVisible key.Binding `keymap:"toggle-column-visible"`
ResetColumns key.Binding `keymap:"reset-columns"`
AddColumn key.Binding `keymap:"add-column"`
DeleteColumn key.Binding `keymap:"delete-column"`
}
type TableKeyBinding struct { type TableKeyBinding struct {
MoveUp key.Binding `keymap:"move-up"` MoveUp key.Binding `keymap:"move-up"`
MoveDown key.Binding `keymap:"move-down"` MoveDown key.Binding `keymap:"move-down"`
@ -30,5 +41,6 @@ type ViewKeyBindings struct {
CycleLayoutForward key.Binding `keymap:"cycle-layout-forward"` CycleLayoutForward key.Binding `keymap:"cycle-layout-forward"`
CycleLayoutBackwards key.Binding `keymap:"cycle-layout-backwards"` CycleLayoutBackwards key.Binding `keymap:"cycle-layout-backwards"`
PromptForCommand key.Binding `keymap:"prompt-for-command"` PromptForCommand key.Binding `keymap:"prompt-for-command"`
ShowColumnOverlay key.Binding `keymap:"show-column-overlay"`
Quit key.Binding `keymap:"quit"` Quit key.Binding `keymap:"quit"`
} }

View file

@ -9,6 +9,7 @@ import (
"github.com/lmika/audax/internal/dynamo-browse/models" "github.com/lmika/audax/internal/dynamo-browse/models"
"github.com/lmika/audax/internal/dynamo-browse/services/itemrenderer" "github.com/lmika/audax/internal/dynamo-browse/services/itemrenderer"
"github.com/lmika/audax/internal/dynamo-browse/ui/keybindings" "github.com/lmika/audax/internal/dynamo-browse/ui/keybindings"
"github.com/lmika/audax/internal/dynamo-browse/ui/teamodels/colselector"
"github.com/lmika/audax/internal/dynamo-browse/ui/teamodels/dialogprompt" "github.com/lmika/audax/internal/dynamo-browse/ui/teamodels/dialogprompt"
"github.com/lmika/audax/internal/dynamo-browse/ui/teamodels/dynamoitemedit" "github.com/lmika/audax/internal/dynamo-browse/ui/teamodels/dynamoitemedit"
"github.com/lmika/audax/internal/dynamo-browse/ui/teamodels/dynamoitemview" "github.com/lmika/audax/internal/dynamo-browse/ui/teamodels/dynamoitemview"
@ -40,7 +41,9 @@ type Model struct {
tableReadController *controllers.TableReadController tableReadController *controllers.TableReadController
tableWriteController *controllers.TableWriteController tableWriteController *controllers.TableWriteController
settingsController *controllers.SettingsController settingsController *controllers.SettingsController
exportController *controllers.ExportController
commandController *commandctrl.CommandController commandController *commandctrl.CommandController
colSelector *colselector.Model
itemEdit *dynamoitemedit.Model itemEdit *dynamoitemedit.Model
statusAndPrompt *statusandprompt.StatusAndPrompt statusAndPrompt *statusandprompt.StatusAndPrompt
tableSelect *tableselect.Model tableSelect *tableselect.Model
@ -57,6 +60,8 @@ type Model struct {
func NewModel( func NewModel(
rc *controllers.TableReadController, rc *controllers.TableReadController,
wc *controllers.TableWriteController, wc *controllers.TableWriteController,
columnsController *controllers.ColumnsController,
exportController *controllers.ExportController,
settingsController *controllers.SettingsController, settingsController *controllers.SettingsController,
itemRendererService *itemrenderer.Service, itemRendererService *itemrenderer.Service,
cc *commandctrl.CommandController, cc *commandctrl.CommandController,
@ -65,11 +70,12 @@ func NewModel(
) Model { ) Model {
uiStyles := styles.DefaultStyles uiStyles := styles.DefaultStyles
dtv := dynamotableview.New(defaultKeyMap.TableView, settingsController, uiStyles) dtv := dynamotableview.New(defaultKeyMap.TableView, columnsController, settingsController, uiStyles)
div := dynamoitemview.New(itemRendererService, uiStyles) div := dynamoitemview.New(itemRendererService, uiStyles)
mainView := layout.NewVBox(layout.LastChildFixedAt(14), dtv, div) mainView := layout.NewVBox(layout.LastChildFixedAt(14), dtv, div)
itemEdit := dynamoitemedit.NewModel(mainView) colSelector := colselector.New(mainView, defaultKeyMap, columnsController)
itemEdit := dynamoitemedit.NewModel(colSelector)
statusAndPrompt := statusandprompt.New(itemEdit, "", uiStyles.StatusAndPrompt) statusAndPrompt := statusandprompt.New(itemEdit, "", uiStyles.StatusAndPrompt)
dialogPrompt := dialogprompt.New(statusAndPrompt) dialogPrompt := dialogprompt.New(statusAndPrompt)
tableSelect := tableselect.New(dialogPrompt, uiStyles) tableSelect := tableselect.New(dialogPrompt, uiStyles)
@ -88,7 +94,7 @@ func NewModel(
if len(args) == 0 { if len(args) == 0 {
return events.Error(errors.New("expected filename")) return events.Error(errors.New("expected filename"))
} }
return rc.ExportCSV(args[0]) return exportController.ExportCSV(args[0])
}, },
"unmark": commandctrl.NoArgCommand(rc.Unmark), "unmark": commandctrl.NoArgCommand(rc.Unmark),
"delete": commandctrl.NoArgCommand(wc.DeleteMarked), "delete": commandctrl.NoArgCommand(wc.DeleteMarked),
@ -174,6 +180,7 @@ func NewModel(
tableWriteController: wc, tableWriteController: wc,
commandController: cc, commandController: cc,
itemEdit: itemEdit, itemEdit: itemEdit,
colSelector: colSelector,
statusAndPrompt: statusAndPrompt, statusAndPrompt: statusAndPrompt,
tableSelect: tableSelect, tableSelect: tableSelect,
root: root, root: root,
@ -184,16 +191,6 @@ func NewModel(
} }
} }
func (m Model) Init() tea.Cmd {
// TODO: this should probably be moved somewhere else
rcFilename := os.ExpandEnv(initRCFilename)
if err := m.commandController.ExecuteFile(rcFilename); err != nil {
log.Println(err)
}
return m.tableReadController.Init
}
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) { switch msg := msg.(type) {
case controllers.SetTableItemView: case controllers.SetTableItemView:
@ -205,15 +202,16 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
events.SetStatus(msg.StatusMessage()), events.SetStatus(msg.StatusMessage()),
) )
case tea.KeyMsg: case tea.KeyMsg:
if !m.statusAndPrompt.InPrompt() && !m.tableSelect.Visible() { // TODO: use modes here
if !m.statusAndPrompt.InPrompt() && !m.tableSelect.Visible() && !m.colSelector.ColSelectorVisible() {
switch { switch {
case key.Matches(msg, m.keyMap.Mark): case key.Matches(msg, m.keyMap.Mark):
if idx := m.tableView.SelectedItemIndex(); idx >= 0 { if idx := m.tableView.SelectedItemIndex(); idx >= 0 {
return m, func() tea.Msg { return m.tableWriteController.ToggleMark(idx) } return m, events.SetTeaMessage(m.tableWriteController.ToggleMark(idx))
} }
case key.Matches(msg, m.keyMap.CopyItemToClipboard): case key.Matches(msg, m.keyMap.CopyItemToClipboard):
if idx := m.tableView.SelectedItemIndex(); idx >= 0 { if idx := m.tableView.SelectedItemIndex(); idx >= 0 {
return m, func() tea.Msg { return m.tableReadController.CopyItemToClipboard(idx) } return m, events.SetTeaMessage(m.tableReadController.CopyItemToClipboard(idx))
} }
case key.Matches(msg, m.keyMap.Rescan): case key.Matches(msg, m.keyMap.Rescan):
return m, m.tableReadController.Rescan return m, m.tableReadController.Rescan
@ -226,22 +224,18 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case key.Matches(msg, m.keyMap.ViewForward): case key.Matches(msg, m.keyMap.ViewForward):
return m, m.tableReadController.ViewForward return m, m.tableReadController.ViewForward
case key.Matches(msg, m.keyMap.CycleLayoutForward): case key.Matches(msg, m.keyMap.CycleLayoutForward):
return m, func() tea.Msg { return m, events.SetTeaMessage(controllers.SetTableItemView{ViewIndex: utils.Cycle(m.mainViewIndex, 1, ViewModeCount)})
return controllers.SetTableItemView{ViewIndex: utils.Cycle(m.mainViewIndex, 1, ViewModeCount)}
}
case key.Matches(msg, m.keyMap.CycleLayoutBackwards): case key.Matches(msg, m.keyMap.CycleLayoutBackwards):
return m, func() tea.Msg { return m, events.SetTeaMessage(controllers.SetTableItemView{ViewIndex: utils.Cycle(m.mainViewIndex, -1, ViewModeCount)})
return controllers.SetTableItemView{ViewIndex: utils.Cycle(m.mainViewIndex, -1, ViewModeCount)}
}
//case "e": //case "e":
// m.itemEdit.Visible() // m.itemEdit.Visible()
// return m, nil // return m, nil
case key.Matches(msg, m.keyMap.ShowColumnOverlay):
return m, events.SetTeaMessage(controllers.ShowColumnOverlay{})
case key.Matches(msg, m.keyMap.PromptForCommand): case key.Matches(msg, m.keyMap.PromptForCommand):
return m, m.commandController.Prompt return m, m.commandController.Prompt
case key.Matches(msg, m.keyMap.PromptForTable): case key.Matches(msg, m.keyMap.PromptForTable):
return m, func() tea.Msg { return m, events.SetTeaMessage(m.tableReadController.ListTables())
return m.tableReadController.ListTables()
}
case key.Matches(msg, m.keyMap.Quit): case key.Matches(msg, m.keyMap.Quit):
return m, tea.Quit return m, tea.Quit
} }
@ -253,6 +247,16 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, cmd return m, cmd
} }
func (m Model) Init() tea.Cmd {
// TODO: this should probably be moved somewhere else
rcFilename := os.ExpandEnv(initRCFilename)
if err := m.commandController.ExecuteFile(rcFilename); err != nil {
log.Println(err)
}
return m.tableReadController.Init
}
func (m Model) View() string { func (m Model) View() string {
return m.root.View() return m.root.View()
} }

View file

@ -0,0 +1,158 @@
package colselector
import (
"github.com/charmbracelet/bubbles/key"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/lmika/audax/internal/common/ui/events"
"github.com/lmika/audax/internal/dynamo-browse/controllers"
"github.com/lmika/audax/internal/dynamo-browse/models/columns"
"github.com/lmika/audax/internal/dynamo-browse/ui/keybindings"
"github.com/lmika/audax/internal/dynamo-browse/ui/teamodels/layout"
table "github.com/lmika/go-bubble-table"
"strings"
)
var frameColor = lipgloss.Color("63")
var frameStyle = lipgloss.NewStyle().
Foreground(frameColor)
var style = lipgloss.NewStyle().
BorderStyle(lipgloss.NormalBorder()).
BorderForeground(frameColor)
type colListModel struct {
keyBinding *keybindings.KeyBindings
colController *controllers.ColumnsController
rows []table.Row
table table.Model
}
func newColListModel(keyBinding *keybindings.KeyBindings, colController *controllers.ColumnsController) *colListModel {
tbl := table.New(table.SimpleColumns([]string{"", "Name"}), 100, 100)
tbl.SetRows([]table.Row{})
return &colListModel{
keyBinding: keyBinding,
colController: colController,
table: tbl,
}
}
func (c *colListModel) Init() tea.Cmd {
return nil
}
func (m *colListModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
switch {
// Column operations
case key.Matches(msg, m.keyBinding.ColumnPopup.ShiftColumnLeft):
return m, events.SetTeaMessage(m.shiftColumnUp(m.table.Cursor()))
case key.Matches(msg, m.keyBinding.ColumnPopup.ShiftColumnRight):
return m, events.SetTeaMessage(m.shiftColumnDown(m.table.Cursor()))
case key.Matches(msg, m.keyBinding.ColumnPopup.ToggleVisible):
return m, events.SetTeaMessage(m.colController.ToggleVisible(m.table.Cursor()))
case key.Matches(msg, m.keyBinding.ColumnPopup.Close):
return m, events.SetTeaMessage(controllers.HideColumnOverlay{})
case key.Matches(msg, m.keyBinding.ColumnPopup.ResetColumns):
return m, events.SetTeaMessage(m.colController.SetColumnsToResultSet())
case key.Matches(msg, m.keyBinding.ColumnPopup.AddColumn):
return m, events.SetTeaMessage(m.colController.AddColumn(m.table.Cursor()))
case key.Matches(msg, m.keyBinding.ColumnPopup.DeleteColumn):
return m, events.SetTeaMessage(m.colController.DeleteColumn(m.table.Cursor()))
// Main table nav
case key.Matches(msg, m.keyBinding.TableView.ColLeft):
return m, events.SetTeaMessage(controllers.MoveLeftmostDisplayedColumnInTableViewBy(-1))
case key.Matches(msg, m.keyBinding.TableView.ColRight):
return m, events.SetTeaMessage(controllers.MoveLeftmostDisplayedColumnInTableViewBy(1))
// Table nav
case key.Matches(msg, m.keyBinding.TableView.MoveUp):
m.table.GoUp()
return m, nil
case key.Matches(msg, m.keyBinding.TableView.MoveDown):
m.table.GoDown()
return m, nil
case key.Matches(msg, m.keyBinding.TableView.PageUp):
m.table.GoPageUp()
return m, nil
case key.Matches(msg, m.keyBinding.TableView.PageDown):
m.table.GoPageDown()
return m, nil
case key.Matches(msg, m.keyBinding.TableView.Home):
m.table.GoTop()
return m, nil
case key.Matches(msg, m.keyBinding.TableView.End):
m.table.GoBottom()
return m, nil
}
}
var cmd tea.Cmd
m.table, cmd = m.table.Update(msg)
return m, cmd
}
func (c *colListModel) View() string {
innerView := lipgloss.JoinVertical(
lipgloss.Top,
lipgloss.PlaceHorizontal(overlayWidth-2, lipgloss.Center, "Columns"),
frameStyle.Render(strings.Repeat(lipgloss.NormalBorder().Top, 48)),
c.table.View(),
)
view := style.Width(overlayWidth - 2).Height(overlayHeight - 2).Render(innerView)
return view
}
func (c *colListModel) Resize(w, h int) layout.ResizingModel {
c.table.SetSize(overlayWidth-4, overlayHeight-4)
return c
}
func (c *colListModel) refreshTable() {
colsFromController := c.colController.Columns()
if len(c.rows) != len(colsFromController.Columns) {
c.setColumnsFromModel(colsFromController)
}
c.table.UpdateView()
}
func (c *colListModel) setColumnsFromModel(cols *columns.Columns) {
if cols == nil {
c.table.SetRows([]table.Row{})
return
}
colNames := make([]table.Row, len(cols.Columns))
for i := range cols.Columns {
colNames[i] = colListRowModel{c}
}
c.rows = colNames
c.table.SetRows(colNames)
if c.table.Cursor() >= len(c.rows) {
c.table.GoBottom()
}
}
func (c *colListModel) shiftColumnUp(cursor int) tea.Msg {
msg := c.colController.ShiftColumnLeft(cursor)
if msg != nil {
c.table.GoUp()
}
return msg
}
func (c *colListModel) shiftColumnDown(cursor int) tea.Msg {
msg := c.colController.ShiftColumnRight(cursor)
if msg != nil {
c.table.GoDown()
}
return msg
}

View file

@ -0,0 +1,74 @@
package colselector
import (
tea "github.com/charmbracelet/bubbletea"
"github.com/lmika/audax/internal/dynamo-browse/controllers"
"github.com/lmika/audax/internal/dynamo-browse/ui/keybindings"
"github.com/lmika/audax/internal/dynamo-browse/ui/teamodels/layout"
"github.com/lmika/audax/internal/dynamo-browse/ui/teamodels/utils"
)
const (
overlayWidth = 50
overlayHeight = 25
)
type Model struct {
columnsController *controllers.ColumnsController
subModel tea.Model
colListModel *colListModel
compositor *layout.Compositor
w, h int
}
func New(submodel tea.Model, keyBinding *keybindings.KeyBindings, columnsController *controllers.ColumnsController) *Model {
colListModel := newColListModel(keyBinding, columnsController)
compositor := layout.NewCompositor(submodel)
return &Model{
columnsController: columnsController,
subModel: submodel,
compositor: compositor,
colListModel: colListModel,
}
}
func (m *Model) Init() tea.Cmd {
return m.subModel.Init()
}
func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cc utils.CmdCollector
switch msg := msg.(type) {
case controllers.ShowColumnOverlay:
m.colListModel.setColumnsFromModel(m.columnsController.Columns())
m.compositor.SetOverlay(m.colListModel, m.w/2-overlayWidth/2, m.h/2-overlayHeight/2, overlayWidth, overlayHeight)
case controllers.HideColumnOverlay:
m.compositor.ClearOverlay()
case controllers.ColumnsUpdated:
m.colListModel.refreshTable()
m.subModel = cc.Collect(m.subModel.Update(msg))
case tea.KeyMsg:
m.compositor = cc.Collect(m.compositor.Update(msg)).(*layout.Compositor)
default:
m.subModel = cc.Collect(m.subModel.Update(msg))
}
return m, cc.Cmd()
}
func (m *Model) View() string {
return m.compositor.View()
}
func (m *Model) Resize(w, h int) layout.ResizingModel {
m.w, m.h = w, h
m.compositor.MoveOverlay(m.w/2-overlayWidth/2, m.h/2-overlayHeight/2)
m.subModel = layout.Resize(m.subModel, w, h)
m.colListModel = layout.Resize(m.colListModel, w, h).(*colListModel)
return m
}
func (m *Model) ColSelectorVisible() bool {
return m.compositor.HasOverlay()
}

View file

@ -0,0 +1,31 @@
package colselector
import (
"fmt"
"github.com/charmbracelet/lipgloss"
table "github.com/lmika/go-bubble-table"
"io"
)
type colListRowModel struct {
m *colListModel
}
func (clr colListRowModel) Render(w io.Writer, model table.Model, index int) {
cols := clr.m.colController.Columns()
if cols == nil {
return
}
var style lipgloss.Style
if index == model.Cursor() {
style = model.Styles.SelectedRow
}
col := clr.m.colController.Columns().Columns[index]
if !col.Hidden {
fmt.Fprintln(w, style.Render(fmt.Sprintf(".\t%v", col.Name)))
} else {
fmt.Fprintln(w, style.Render(fmt.Sprintf("✕\t%v", col.Name)))
}
}

View file

@ -5,7 +5,7 @@ type columnModel struct {
} }
func (cm columnModel) Len() int { func (cm columnModel) Len() int {
return len(cm.m.resultSet.Columns()[cm.m.colOffset:]) + 1 return len(cm.m.columns[cm.m.colOffset:]) + 1
} }
func (cm columnModel) Header(index int) string { func (cm columnModel) Header(index int) string {
@ -13,5 +13,5 @@ func (cm columnModel) Header(index int) string {
return "" return ""
} }
return cm.m.resultSet.Columns()[cm.m.colOffset+index-1] return cm.m.columns[cm.m.colOffset+index-1].Name
} }

View file

@ -6,6 +6,7 @@ import (
"github.com/charmbracelet/lipgloss" "github.com/charmbracelet/lipgloss"
"github.com/lmika/audax/internal/dynamo-browse/controllers" "github.com/lmika/audax/internal/dynamo-browse/controllers"
"github.com/lmika/audax/internal/dynamo-browse/models" "github.com/lmika/audax/internal/dynamo-browse/models"
"github.com/lmika/audax/internal/dynamo-browse/models/columns"
"github.com/lmika/audax/internal/dynamo-browse/ui/keybindings" "github.com/lmika/audax/internal/dynamo-browse/ui/keybindings"
"github.com/lmika/audax/internal/dynamo-browse/ui/teamodels/dynamoitemview" "github.com/lmika/audax/internal/dynamo-browse/ui/teamodels/dynamoitemview"
"github.com/lmika/audax/internal/dynamo-browse/ui/teamodels/frame" "github.com/lmika/audax/internal/dynamo-browse/ui/teamodels/frame"
@ -26,35 +27,42 @@ type Setting interface {
IsReadOnly() bool IsReadOnly() bool
} }
type ColumnsProvider interface {
Columns() *columns.Columns
}
type Model struct { type Model struct {
frameTitle frame.FrameTitle frameTitle frame.FrameTitle
table table.Model table table.Model
w, h int w, h int
keyBinding *keybindings.TableKeyBinding keyBinding *keybindings.TableKeyBinding
setting Setting setting Setting
columnsProvider ColumnsProvider
// model state // model state
isReadOnly bool isReadOnly bool
colOffset int colOffset int
rows []table.Row rows []table.Row
columns []columns.Column
resultSet *models.ResultSet resultSet *models.ResultSet
} }
func New(keyBinding *keybindings.TableKeyBinding, setting Setting, uiStyles styles.Styles) *Model { func New(keyBinding *keybindings.TableKeyBinding, columnsProvider ColumnsProvider, setting Setting, uiStyles styles.Styles) *Model {
tbl := table.New(table.SimpleColumns([]string{"pk", "sk"}), 100, 100)
rows := make([]table.Row, 0)
tbl.SetRows(rows)
frameTitle := frame.NewFrameTitle("No table", true, uiStyles.Frames) frameTitle := frame.NewFrameTitle("No table", true, uiStyles.Frames)
isReadOnly := setting.IsReadOnly() isReadOnly := setting.IsReadOnly()
return &Model{ model := &Model{
isReadOnly: isReadOnly, isReadOnly: isReadOnly,
frameTitle: frameTitle, frameTitle: frameTitle,
table: tbl,
keyBinding: keyBinding, keyBinding: keyBinding,
setting: setting, setting: setting,
columnsProvider: columnsProvider,
} }
model.table = table.New(columnModel{model}, 100, 100)
model.table.SetRows([]table.Row{})
return model
} }
func (m *Model) Init() tea.Cmd { func (m *Model) Init() tea.Cmd {
@ -67,9 +75,15 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.resultSet = msg.ResultSet m.resultSet = msg.ResultSet
m.updateTable() m.updateTable()
return m, m.postSelectedItemChanged return m, m.postSelectedItemChanged
case controllers.ColumnsUpdated:
m.rebuildTable(&m.table)
return m, m.postSelectedItemChanged
case controllers.SettingsUpdated: case controllers.SettingsUpdated:
m.updateTableHeading() m.updateTableHeading()
return m, nil return m, nil
case controllers.MoveLeftmostDisplayedColumnInTableViewBy:
m.setLeftmostDisplayedColumn(m.colOffset + int(msg))
return m, nil
case tea.KeyMsg: case tea.KeyMsg:
switch { switch {
// Table nav // Table nav
@ -106,8 +120,8 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
func (m *Model) setLeftmostDisplayedColumn(newCol int) { func (m *Model) setLeftmostDisplayedColumn(newCol int) {
if newCol < 0 { if newCol < 0 {
m.colOffset = 0 m.colOffset = 0
} else if newCol >= len(m.resultSet.Columns()) { } else if newCol >= len(m.columnsProvider.Columns().Columns) {
m.colOffset = len(m.resultSet.Columns()) - 1 m.colOffset = len(m.columnsProvider.Columns().Columns) - 1
} else { } else {
m.colOffset = newCol m.colOffset = newCol
} }
@ -139,14 +153,29 @@ func (m *Model) updateTableHeading() {
func (m *Model) updateTable() { func (m *Model) updateTable() {
m.updateTableHeading() m.updateTableHeading()
m.colOffset = 0 m.colOffset = 0
m.rebuildTable() m.rebuildTable(nil)
} }
func (m *Model) rebuildTable() { func (m *Model) rebuildTable(targetTbl *table.Model) {
var tbl table.Model
resultSet := m.resultSet resultSet := m.resultSet
newTbl := table.New(columnModel{m}, m.w, m.h-m.frameTitle.HeaderHeight()) // Use the target table model if you can, but if it's nil or the number of rows is smaller than the
// existing table, create a new one
if targetTbl == nil || len(resultSet.Items()) > len(m.rows) {
tbl = table.New(columnModel{m}, m.w, m.h-m.frameTitle.HeaderHeight())
if targetTbl != nil {
tbl.GoBottom()
}
} else {
tbl = *targetTbl
}
m.columns = m.columnsProvider.Columns().VisibleColumns()
newRows := make([]table.Row, 0) newRows := make([]table.Row, 0)
for i, r := range resultSet.Items() { for i, r := range resultSet.Items() {
if resultSet.Hidden(i) { if resultSet.Hidden(i) {
continue continue
@ -161,8 +190,9 @@ func (m *Model) rebuildTable() {
} }
m.rows = newRows m.rows = newRows
newTbl.SetRows(newRows) tbl.SetRows(newRows)
m.table = newTbl
m.table = tbl
} }
func (m *Model) SelectedItemIndex() int { func (m *Model) SelectedItemIndex() int {

View file

@ -3,6 +3,7 @@ package dynamotableview
import ( import (
"fmt" "fmt"
"github.com/charmbracelet/lipgloss" "github.com/charmbracelet/lipgloss"
"github.com/lmika/audax/internal/dynamo-browse/models/itemrender"
"io" "io"
"strings" "strings"
@ -63,12 +64,12 @@ func (mtr itemTableRow) Render(w io.Writer, model table.Model, index int) {
sb.WriteString(metaInfoStyle.Render("⋅\t")) sb.WriteString(metaInfoStyle.Render("⋅\t"))
} }
for i, colName := range mtr.resultSet.Columns()[mtr.model.colOffset:] { for i, col := range mtr.model.columns[mtr.model.colOffset:] {
if i > 0 { if i > 0 {
sb.WriteString(style.Render("\t")) sb.WriteString(style.Render("\t"))
} }
if r := mtr.item.Renderer(colName); r != nil { if r := itemrender.ToRenderer(col.Evaluator.EvaluateForItem(mtr.item)); r != nil {
sb.WriteString(style.Render(r.StringValue())) sb.WriteString(style.Render(r.StringValue()))
if mi := r.MetaInfo(); mi != "" { if mi := r.MetaInfo(); mi != "" {
sb.WriteString(metaInfoStyle.Render(mi)) sb.WriteString(metaInfoStyle.Render(mi))

View file

@ -4,18 +4,21 @@ import (
"bufio" "bufio"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss" "github.com/charmbracelet/lipgloss"
"github.com/mattn/go-runewidth"
"github.com/muesli/ansi"
"github.com/muesli/reflow/truncate"
"strings" "strings"
) )
type Compositor struct { type Compositor struct {
background ResizingModel background tea.Model
foreground ResizingModel foreground tea.Model
foreX, foreY int foreX, foreY int
foreW, foreH int foreW, foreH int
} }
func NewCompositor(background ResizingModel) *Compositor { func NewCompositor(background tea.Model) *Compositor {
return &Compositor{ return &Compositor{
background: background, background: background,
} }
@ -26,9 +29,12 @@ func (c *Compositor) Init() tea.Cmd {
} }
func (c *Compositor) Update(msg tea.Msg) (tea.Model, tea.Cmd) { func (c *Compositor) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
// TODO: allow the compositor the var cmd tea.Cmd
newM, cmd := c.background.Update(msg) if c.foreground != nil {
c.background = newM.(ResizingModel) c.foreground, cmd = c.foreground.Update(msg)
} else {
c.background, cmd = c.background.Update(msg)
}
return c, cmd return c, cmd
} }
@ -38,6 +44,18 @@ func (c *Compositor) SetOverlay(m ResizingModel, x, y, w, h int) {
c.foreW, c.foreH = w, h c.foreW, c.foreH = w, h
} }
func (c *Compositor) MoveOverlay(x, y int) {
c.foreX, c.foreY = x, y
}
func (c *Compositor) ClearOverlay() {
c.foreground = nil
}
func (c *Compositor) HasOverlay() bool {
return c.foreground != nil
}
func (c *Compositor) View() string { func (c *Compositor) View() string {
if c.foreground == nil { if c.foreground == nil {
return c.background.View() return c.background.View()
@ -46,6 +64,7 @@ func (c *Compositor) View() string {
// Need to compose // Need to compose
backgroundView := c.background.View() backgroundView := c.background.View()
foregroundViewLines := strings.Split(c.foreground.View(), "\n") foregroundViewLines := strings.Split(c.foreground.View(), "\n")
_ = foregroundViewLines
backgroundScanner := bufio.NewScanner(strings.NewReader(backgroundView)) backgroundScanner := bufio.NewScanner(strings.NewReader(backgroundView))
compositeOutput := new(strings.Builder) compositeOutput := new(strings.Builder)
@ -58,7 +77,7 @@ func (c *Compositor) View() string {
line := backgroundScanner.Text() line := backgroundScanner.Text()
if r >= c.foreY && r < c.foreY+c.foreH { if r >= c.foreY && r < c.foreY+c.foreH {
compositeOutput.WriteString(line[:c.foreX]) compositeOutput.WriteString(truncate.String(line, uint(c.foreX)))
foregroundScanPos := r - c.foreY foregroundScanPos := r - c.foreY
if foregroundScanPos < len(foregroundViewLines) { if foregroundScanPos < len(foregroundViewLines) {
@ -66,7 +85,10 @@ func (c *Compositor) View() string {
compositeOutput.WriteString(lipgloss.PlaceHorizontal(c.foreW, lipgloss.Left, displayLine, lipgloss.WithWhitespaceChars(" "))) compositeOutput.WriteString(lipgloss.PlaceHorizontal(c.foreW, lipgloss.Left, displayLine, lipgloss.WithWhitespaceChars(" ")))
} }
compositeOutput.WriteString(line[c.foreX+c.foreW:]) rightStr := c.renderBackgroundUpTo(line, c.foreX+c.foreW)
// Need to find a way to cut the string here
compositeOutput.WriteString(rightStr)
} else { } else {
compositeOutput.WriteString(line) compositeOutput.WriteString(line)
} }
@ -77,9 +99,33 @@ func (c *Compositor) View() string {
} }
func (c *Compositor) Resize(w, h int) ResizingModel { func (c *Compositor) Resize(w, h int) ResizingModel {
c.background = c.background.Resize(w, h) c.background = Resize(c.background, w, h)
if c.foreground != nil { if c.foreground != nil {
c.foreground = c.foreground.Resize(c.foreW, c.foreH) c.foreground = Resize(c.foreground, c.foreW, c.foreH)
} }
return c return c
} }
func (c *Compositor) renderBackgroundUpTo(line string, x int) string {
ansiSequences := new(strings.Builder)
posX := 0
inAnsi := false
for i, c := range line {
if c == ansi.Marker {
ansiSequences.WriteRune(c)
inAnsi = true
} else if inAnsi {
ansiSequences.WriteRune(c)
if ansi.IsTerminator(c) {
inAnsi = false
}
} else {
if posX >= x {
return ansiSequences.String() + line[i:]
}
posX += runewidth.RuneWidth(c)
}
}
return ""
}

View file

@ -2,13 +2,13 @@ package main
import ( import (
"context" "context"
"flag"
"fmt" "fmt"
"github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/config" "github.com/aws/aws-sdk-go-v2/config"
"github.com/aws/aws-sdk-go-v2/service/dynamodb" "github.com/aws/aws-sdk-go-v2/service/dynamodb"
"github.com/aws/aws-sdk-go-v2/service/dynamodb/types" "github.com/aws/aws-sdk-go-v2/service/dynamodb/types"
"github.com/brianvoe/gofakeit/v6" "github.com/brianvoe/gofakeit/v6"
"github.com/google/uuid"
"github.com/lmika/audax/internal/dynamo-browse/models" "github.com/lmika/audax/internal/dynamo-browse/models"
"github.com/lmika/audax/internal/dynamo-browse/providers/dynamo" "github.com/lmika/audax/internal/dynamo-browse/providers/dynamo"
"github.com/lmika/audax/internal/dynamo-browse/services/tables" "github.com/lmika/audax/internal/dynamo-browse/services/tables"
@ -18,9 +18,13 @@ import (
) )
func main() { func main() {
var flagSeed = flag.Int64("seed", 0, "random seed to use")
var flagCount = flag.Int("count", 500, "number of items to produce")
flag.Parse()
ctx := context.Background() ctx := context.Background()
tableName := "business-addresses" tableName := "business-addresses"
totalItems := 500 totalItems := *flagCount
cfg, err := config.LoadDefaultConfig(ctx) cfg, err := config.LoadDefaultConfig(ctx)
if err != nil { if err != nil {
@ -53,8 +57,11 @@ func main() {
_, _ = tableService, tableInfo _, _ = tableService, tableInfo
log.Printf("using seed: %v", *flagSeed)
gofakeit.Seed(*flagSeed)
for i := 0; i < totalItems; i++ { for i := 0; i < totalItems; i++ {
key := uuid.New().String() key := gofakeit.UUID()
if err := tableService.Put(ctx, tableInfo, models.Item{ if err := tableService.Put(ctx, tableInfo, models.Item{
"pk": &types.AttributeValueMemberS{Value: key}, "pk": &types.AttributeValueMemberS{Value: key},
"sk": &types.AttributeValueMemberS{Value: key}, "sk": &types.AttributeValueMemberS{Value: key},
@ -64,6 +71,12 @@ func main() {
"phone": &types.AttributeValueMemberN{Value: gofakeit.Phone()}, "phone": &types.AttributeValueMemberN{Value: gofakeit.Phone()},
"web": &types.AttributeValueMemberS{Value: gofakeit.URL()}, "web": &types.AttributeValueMemberS{Value: gofakeit.URL()},
"officeOpened": &types.AttributeValueMemberBOOL{Value: gofakeit.Bool()}, "officeOpened": &types.AttributeValueMemberBOOL{Value: gofakeit.Bool()},
"colors": &types.AttributeValueMemberM{
Value: map[string]types.AttributeValue{
"door": &types.AttributeValueMemberS{Value: gofakeit.Color()},
"front": &types.AttributeValueMemberS{Value: gofakeit.Color()},
},
},
"ratings": &types.AttributeValueMemberL{Value: []types.AttributeValue{ "ratings": &types.AttributeValueMemberL{Value: []types.AttributeValue{
&types.AttributeValueMemberN{Value: fmt.Sprint(gofakeit.IntRange(0, 5))}, &types.AttributeValueMemberN{Value: fmt.Sprint(gofakeit.IntRange(0, 5))},
&types.AttributeValueMemberN{Value: fmt.Sprint(gofakeit.IntRange(0, 5))}, &types.AttributeValueMemberN{Value: fmt.Sprint(gofakeit.IntRange(0, 5))},