From a6b11c82af6cfd223b662da5425c4cc86cc63afa Mon Sep 17 00:00:00 2001 From: Jahvon Dockery Date: Wed, 13 Aug 2025 19:20:48 -0400 Subject: [PATCH 1/5] refactor!: fix text fmt printing and rename funcs --- io/logger.go | 20 +++--- io/mocks/mock_logger.go | 148 ++++++++++++++++++++-------------------- io/output.go | 12 +--- io/output_test.go | 16 ++--- io/types.go | 14 ++-- 5 files changed, 101 insertions(+), 109 deletions(-) diff --git a/io/logger.go b/io/logger.go index 36ae263..1d5b5a5 100644 --- a/io/logger.go +++ b/io/logger.go @@ -225,14 +225,14 @@ func (l *StandardLogger) Debugf(msg string, args ...any) { } } -func (l *StandardLogger) Error(err error, msg string) { +func (l *StandardLogger) WrapError(err error, msg string) { if msg == "" { - l.Errorf(err.Error()) + l.Error(err.Error()) return } else if l.mode == Hidden { return } - l.Errorx(err.Error(), "err", err) + l.Error(err.Error(), "err", err) } func (l *StandardLogger) Errorf(msg string, args ...any) { @@ -288,7 +288,7 @@ func (l *StandardLogger) Fatalf(msg string, args ...any) { } } -func (l *StandardLogger) Infox(msg string, kv ...any) { +func (l *StandardLogger) Info(msg string, kv ...any) { l.syncLoggerFormat() if l.mode == Hidden { return @@ -299,7 +299,7 @@ func (l *StandardLogger) Infox(msg string, kv ...any) { } } -func (l *StandardLogger) Noticex(msg string, kv ...any) { +func (l *StandardLogger) Notice(msg string, kv ...any) { if l.mode == Hidden { return } @@ -310,7 +310,7 @@ func (l *StandardLogger) Noticex(msg string, kv ...any) { } } -func (l *StandardLogger) Debugx(msg string, kv ...any) { +func (l *StandardLogger) Debug(msg string, kv ...any) { if l.mode == Hidden { return } @@ -321,7 +321,7 @@ func (l *StandardLogger) Debugx(msg string, kv ...any) { } } -func (l *StandardLogger) Errorx(msg string, kv ...any) { +func (l *StandardLogger) Error(msg string, kv ...any) { if l.mode == Hidden { return } @@ -332,7 +332,7 @@ func (l *StandardLogger) Errorx(msg string, kv ...any) { } } -func (l *StandardLogger) Warnx(msg string, kv ...any) { +func (l *StandardLogger) Warn(msg string, kv ...any) { if l.mode == Hidden { return } @@ -343,7 +343,7 @@ func (l *StandardLogger) Warnx(msg string, kv ...any) { } } -func (l *StandardLogger) Fatalx(msg string, kv ...any) { +func (l *StandardLogger) Fatal(msg string, kv ...any) { l.syncLoggerFormat() if l.archiveHandler != nil { l.archiveHandler.Error(msg, kv...) @@ -438,6 +438,6 @@ func (l *StandardLogger) syncLoggerFormat() { } } -func defaultExit(_ string, args ...any) { +func defaultExit(_ string, _ ...any) { os.Exit(1) } diff --git a/io/mocks/mock_logger.go b/io/mocks/mock_logger.go index 2a7cbed..944dd8e 100644 --- a/io/mocks/mock_logger.go +++ b/io/mocks/mock_logger.go @@ -39,50 +39,55 @@ func (m *MockLogger) EXPECT() *MockLoggerMockRecorder { return m.recorder } -// Debugf mocks base method. -func (m *MockLogger) Debugf(arg0 string, arg1 ...any) { +// Debug mocks base method. +func (m *MockLogger) Debug(arg0 string, arg1 ...any) { m.ctrl.T.Helper() varargs := []any{arg0} for _, a := range arg1 { varargs = append(varargs, a) } - m.ctrl.Call(m, "Debugf", varargs...) + m.ctrl.Call(m, "Debug", varargs...) } -// Debugf indicates an expected call of Debugf. -func (mr *MockLoggerMockRecorder) Debugf(arg0 any, arg1 ...any) *gomock.Call { +// Debug indicates an expected call of Debug. +func (mr *MockLoggerMockRecorder) Debug(arg0 any, arg1 ...any) *gomock.Call { mr.mock.ctrl.T.Helper() varargs := append([]any{arg0}, arg1...) - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Debugf", reflect.TypeOf((*MockLogger)(nil).Debugf), varargs...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Debug", reflect.TypeOf((*MockLogger)(nil).Debug), varargs...) } -// Debugx mocks base method. -func (m *MockLogger) Debugx(arg0 string, arg1 ...any) { +// Debugf mocks base method. +func (m *MockLogger) Debugf(arg0 string, arg1 ...any) { m.ctrl.T.Helper() varargs := []any{arg0} for _, a := range arg1 { varargs = append(varargs, a) } - m.ctrl.Call(m, "Debugx", varargs...) + m.ctrl.Call(m, "Debugf", varargs...) } -// Debugx indicates an expected call of Debugx. -func (mr *MockLoggerMockRecorder) Debugx(arg0 any, arg1 ...any) *gomock.Call { +// Debugf indicates an expected call of Debugf. +func (mr *MockLoggerMockRecorder) Debugf(arg0 any, arg1 ...any) *gomock.Call { mr.mock.ctrl.T.Helper() varargs := append([]any{arg0}, arg1...) - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Debugx", reflect.TypeOf((*MockLogger)(nil).Debugx), varargs...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Debugf", reflect.TypeOf((*MockLogger)(nil).Debugf), varargs...) } // Error mocks base method. -func (m *MockLogger) Error(arg0 error, arg1 string) { +func (m *MockLogger) Error(arg0 string, arg1 ...any) { m.ctrl.T.Helper() - m.ctrl.Call(m, "Error", arg0, arg1) + varargs := []any{arg0} + for _, a := range arg1 { + varargs = append(varargs, a) + } + m.ctrl.Call(m, "Error", varargs...) } // Error indicates an expected call of Error. -func (mr *MockLoggerMockRecorder) Error(arg0, arg1 any) *gomock.Call { +func (mr *MockLoggerMockRecorder) Error(arg0 any, arg1 ...any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Error", reflect.TypeOf((*MockLogger)(nil).Error), arg0, arg1) + varargs := append([]any{arg0}, arg1...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Error", reflect.TypeOf((*MockLogger)(nil).Error), varargs...) } // Errorf mocks base method. @@ -102,21 +107,21 @@ func (mr *MockLoggerMockRecorder) Errorf(arg0 any, arg1 ...any) *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Errorf", reflect.TypeOf((*MockLogger)(nil).Errorf), varargs...) } -// Errorx mocks base method. -func (m *MockLogger) Errorx(arg0 string, arg1 ...any) { +// Fatal mocks base method. +func (m *MockLogger) Fatal(arg0 string, arg1 ...any) { m.ctrl.T.Helper() varargs := []any{arg0} for _, a := range arg1 { varargs = append(varargs, a) } - m.ctrl.Call(m, "Errorx", varargs...) + m.ctrl.Call(m, "Fatal", varargs...) } -// Errorx indicates an expected call of Errorx. -func (mr *MockLoggerMockRecorder) Errorx(arg0 any, arg1 ...any) *gomock.Call { +// Fatal indicates an expected call of Fatal. +func (mr *MockLoggerMockRecorder) Fatal(arg0 any, arg1 ...any) *gomock.Call { mr.mock.ctrl.T.Helper() varargs := append([]any{arg0}, arg1...) - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Errorx", reflect.TypeOf((*MockLogger)(nil).Errorx), varargs...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Fatal", reflect.TypeOf((*MockLogger)(nil).Fatal), varargs...) } // FatalErr mocks base method. @@ -148,23 +153,6 @@ func (mr *MockLoggerMockRecorder) Fatalf(arg0 any, arg1 ...any) *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Fatalf", reflect.TypeOf((*MockLogger)(nil).Fatalf), varargs...) } -// Fatalx mocks base method. -func (m *MockLogger) Fatalx(arg0 string, arg1 ...any) { - m.ctrl.T.Helper() - varargs := []any{arg0} - for _, a := range arg1 { - varargs = append(varargs, a) - } - m.ctrl.Call(m, "Fatalx", varargs...) -} - -// Fatalx indicates an expected call of Fatalx. -func (mr *MockLoggerMockRecorder) Fatalx(arg0 any, arg1 ...any) *gomock.Call { - mr.mock.ctrl.T.Helper() - varargs := append([]any{arg0}, arg1...) - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Fatalx", reflect.TypeOf((*MockLogger)(nil).Fatalx), varargs...) -} - // Flush mocks base method. func (m *MockLogger) Flush() error { m.ctrl.T.Helper() @@ -179,38 +167,38 @@ func (mr *MockLoggerMockRecorder) Flush() *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Flush", reflect.TypeOf((*MockLogger)(nil).Flush)) } -// Infof mocks base method. -func (m *MockLogger) Infof(arg0 string, arg1 ...any) { +// Info mocks base method. +func (m *MockLogger) Info(arg0 string, arg1 ...any) { m.ctrl.T.Helper() varargs := []any{arg0} for _, a := range arg1 { varargs = append(varargs, a) } - m.ctrl.Call(m, "Infof", varargs...) + m.ctrl.Call(m, "Info", varargs...) } -// Infof indicates an expected call of Infof. -func (mr *MockLoggerMockRecorder) Infof(arg0 any, arg1 ...any) *gomock.Call { +// Info indicates an expected call of Info. +func (mr *MockLoggerMockRecorder) Info(arg0 any, arg1 ...any) *gomock.Call { mr.mock.ctrl.T.Helper() varargs := append([]any{arg0}, arg1...) - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Infof", reflect.TypeOf((*MockLogger)(nil).Infof), varargs...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Info", reflect.TypeOf((*MockLogger)(nil).Info), varargs...) } -// Infox mocks base method. -func (m *MockLogger) Infox(arg0 string, arg1 ...any) { +// Infof mocks base method. +func (m *MockLogger) Infof(arg0 string, arg1 ...any) { m.ctrl.T.Helper() varargs := []any{arg0} for _, a := range arg1 { varargs = append(varargs, a) } - m.ctrl.Call(m, "Infox", varargs...) + m.ctrl.Call(m, "Infof", varargs...) } -// Infox indicates an expected call of Infox. -func (mr *MockLoggerMockRecorder) Infox(arg0 any, arg1 ...any) *gomock.Call { +// Infof indicates an expected call of Infof. +func (mr *MockLoggerMockRecorder) Infof(arg0 any, arg1 ...any) *gomock.Call { mr.mock.ctrl.T.Helper() varargs := append([]any{arg0}, arg1...) - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Infox", reflect.TypeOf((*MockLogger)(nil).Infox), varargs...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Infof", reflect.TypeOf((*MockLogger)(nil).Infof), varargs...) } // LogMode mocks base method. @@ -227,38 +215,38 @@ func (mr *MockLoggerMockRecorder) LogMode() *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "LogMode", reflect.TypeOf((*MockLogger)(nil).LogMode)) } -// Noticef mocks base method. -func (m *MockLogger) Noticef(arg0 string, arg1 ...any) { +// Notice mocks base method. +func (m *MockLogger) Notice(arg0 string, arg1 ...any) { m.ctrl.T.Helper() varargs := []any{arg0} for _, a := range arg1 { varargs = append(varargs, a) } - m.ctrl.Call(m, "Noticef", varargs...) + m.ctrl.Call(m, "Notice", varargs...) } -// Noticef indicates an expected call of Noticef. -func (mr *MockLoggerMockRecorder) Noticef(arg0 any, arg1 ...any) *gomock.Call { +// Notice indicates an expected call of Notice. +func (mr *MockLoggerMockRecorder) Notice(arg0 any, arg1 ...any) *gomock.Call { mr.mock.ctrl.T.Helper() varargs := append([]any{arg0}, arg1...) - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Noticef", reflect.TypeOf((*MockLogger)(nil).Noticef), varargs...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Notice", reflect.TypeOf((*MockLogger)(nil).Notice), varargs...) } -// Noticex mocks base method. -func (m *MockLogger) Noticex(arg0 string, arg1 ...any) { +// Noticef mocks base method. +func (m *MockLogger) Noticef(arg0 string, arg1 ...any) { m.ctrl.T.Helper() varargs := []any{arg0} for _, a := range arg1 { varargs = append(varargs, a) } - m.ctrl.Call(m, "Noticex", varargs...) + m.ctrl.Call(m, "Noticef", varargs...) } -// Noticex indicates an expected call of Noticex. -func (mr *MockLoggerMockRecorder) Noticex(arg0 any, arg1 ...any) *gomock.Call { +// Noticef indicates an expected call of Noticef. +func (mr *MockLoggerMockRecorder) Noticef(arg0 any, arg1 ...any) *gomock.Call { mr.mock.ctrl.T.Helper() varargs := append([]any{arg0}, arg1...) - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Noticex", reflect.TypeOf((*MockLogger)(nil).Noticex), varargs...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Noticef", reflect.TypeOf((*MockLogger)(nil).Noticef), varargs...) } // PlainTextDebug mocks base method. @@ -381,6 +369,23 @@ func (mr *MockLoggerMockRecorder) SetMode(arg0 any) *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetMode", reflect.TypeOf((*MockLogger)(nil).SetMode), arg0) } +// Warn mocks base method. +func (m *MockLogger) Warn(arg0 string, arg1 ...any) { + m.ctrl.T.Helper() + varargs := []any{arg0} + for _, a := range arg1 { + varargs = append(varargs, a) + } + m.ctrl.Call(m, "Warn", varargs...) +} + +// Warn indicates an expected call of Warn. +func (mr *MockLoggerMockRecorder) Warn(arg0 any, arg1 ...any) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]any{arg0}, arg1...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Warn", reflect.TypeOf((*MockLogger)(nil).Warn), varargs...) +} + // Warnf mocks base method. func (m *MockLogger) Warnf(arg0 string, arg1 ...any) { m.ctrl.T.Helper() @@ -398,19 +403,14 @@ func (mr *MockLoggerMockRecorder) Warnf(arg0 any, arg1 ...any) *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Warnf", reflect.TypeOf((*MockLogger)(nil).Warnf), varargs...) } -// Warnx mocks base method. -func (m *MockLogger) Warnx(arg0 string, arg1 ...any) { +// WrapError mocks base method. +func (m *MockLogger) WrapError(arg0 error, arg1 string) { m.ctrl.T.Helper() - varargs := []any{arg0} - for _, a := range arg1 { - varargs = append(varargs, a) - } - m.ctrl.Call(m, "Warnx", varargs...) + m.ctrl.Call(m, "WrapError", arg0, arg1) } -// Warnx indicates an expected call of Warnx. -func (mr *MockLoggerMockRecorder) Warnx(arg0 any, arg1 ...any) *gomock.Call { +// WrapError indicates an expected call of WrapError. +func (mr *MockLoggerMockRecorder) WrapError(arg0, arg1 any) *gomock.Call { mr.mock.ctrl.T.Helper() - varargs := append([]any{arg0}, arg1...) - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Warnx", reflect.TypeOf((*MockLogger)(nil).Warnx), varargs...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "WrapError", reflect.TypeOf((*MockLogger)(nil).WrapError), arg0, arg1) } diff --git a/io/output.go b/io/output.go index 5495597..28bd74c 100644 --- a/io/output.go +++ b/io/output.go @@ -38,11 +38,7 @@ func (w StdOutWriter) Write(p []byte) (n int, err error) { if strings.TrimSpace(line) == "" { continue } - if len(w.LogFields) > 0 { - w.Logger.Infox(line, w.LogFields...) - } else { - w.Logger.Infof(line) - } + w.Logger.Info(line, w.LogFields...) } default: return len(p), fmt.Errorf("unknown log mode %v", curMode) @@ -83,11 +79,7 @@ func (w StdErrWriter) Write(p []byte) (n int, err error) { if strings.TrimSpace(line) == "" { continue } - if len(w.LogFields) > 0 { - w.Logger.Noticex(line, w.LogFields...) - } else { - w.Logger.Noticef(line) - } + w.Logger.Notice(line, w.LogFields...) } default: return len(p), fmt.Errorf("unknown log mode %v", w.LogMode) diff --git a/io/output_test.go b/io/output_test.go index 4deca91..6dc968c 100644 --- a/io/output_test.go +++ b/io/output_test.go @@ -37,10 +37,10 @@ func TestStdOutWriter_WriteLogFmt(t *testing.T) { input := []byte("line 1\nline 2\nline 3\nline 4") mockLogger.EXPECT().LogMode().Return(io.Logfmt).AnyTimes() - mockLogger.EXPECT().Infox("line 1", fields...) - mockLogger.EXPECT().Infox("line 2", fields...) - mockLogger.EXPECT().Infox("line 3", fields...) - mockLogger.EXPECT().Infox("line 4", fields...) + mockLogger.EXPECT().Info("line 1", fields...) + mockLogger.EXPECT().Info("line 2", fields...) + mockLogger.EXPECT().Info("line 3", fields...) + mockLogger.EXPECT().Info("line 4", fields...) _, err := writer.Write(input) if err != nil { @@ -92,10 +92,10 @@ func TestStdErrWriter_WriteLogFmt(t *testing.T) { input := []byte("line 1\nline 2\nline 3\nline 4") mockLogger.EXPECT().LogMode().Return(io.Logfmt).AnyTimes() - mockLogger.EXPECT().Noticex("line 1", fields...) - mockLogger.EXPECT().Noticex("line 2", fields...) - mockLogger.EXPECT().Noticex("line 3", fields...) - mockLogger.EXPECT().Noticex("line 4", fields...) + mockLogger.EXPECT().Notice("line 1", fields...) + mockLogger.EXPECT().Notice("line 2", fields...) + mockLogger.EXPECT().Notice("line 3", fields...) + mockLogger.EXPECT().Notice("line 4", fields...) _, err := writer.Write(input) if err != nil { diff --git a/io/types.go b/io/types.go index 76dcba7..61e050a 100644 --- a/io/types.go +++ b/io/types.go @@ -37,17 +37,17 @@ type Logger interface { Infof(msg string, args ...any) Noticef(msg string, args ...any) Debugf(msg string, args ...any) - Error(err error, msg string) + WrapError(err error, msg string) Errorf(msg string, args ...any) Warnf(msg string, args ...any) Fatalf(msg string, args ...any) - Infox(msg string, kv ...any) - Noticex(msg string, kv ...any) - Debugx(msg string, kv ...any) - Errorx(msg string, kv ...any) - Warnx(msg string, kv ...any) - Fatalx(msg string, kv ...any) + Info(msg string, kv ...any) + Notice(msg string, kv ...any) + Debug(msg string, kv ...any) + Error(msg string, kv ...any) + Warn(msg string, kv ...any) + Fatal(msg string, kv ...any) Print(data string) Println(data string) From 0c4ecc6704b0b648ae0ba9ba959768fa3c8878d9 Mon Sep 17 00:00:00 2001 From: Jahvon Dockery Date: Wed, 13 Aug 2025 19:22:59 -0400 Subject: [PATCH 2/5] fix: return without err for piped form EOF --- views/form.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/views/form.go b/views/form.go index e3fdb6d..057749e 100644 --- a/views/form.go +++ b/views/form.go @@ -339,7 +339,7 @@ func readPipedInput(in *os.File, fields []*FormField) error { if err != nil && !errors.Is(err, io.EOF) { return fmt.Errorf("error reading input line: %w", err) } else if line == "" && errors.Is(err, io.EOF) { - return fmt.Errorf("not enough input lines") + return nil } if !field.Required && line == "" && field.Default != "" { line = field.Default From 920b56f3b4479510a4a7bb8a4dc0d85377529e3a Mon Sep 17 00:00:00 2001 From: Jahvon Dockery Date: Wed, 13 Aug 2025 20:19:02 -0400 Subject: [PATCH 3/5] feat: simple table view --- sample/main.go | 91 +++++++++++++ views/table.go | 358 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 449 insertions(+) create mode 100644 views/table.go diff --git a/sample/main.go b/sample/main.go index 179060b..fdf798e 100644 --- a/sample/main.go +++ b/sample/main.go @@ -9,6 +9,7 @@ import ( "github.com/flowexec/tuikit" sampleTypes "github.com/flowexec/tuikit/sample/types" + "github.com/flowexec/tuikit/themes" "github.com/flowexec/tuikit/types" "github.com/flowexec/tuikit/views" ) @@ -53,6 +54,96 @@ func main() { &types.EntityInfo{ID: "mark", Header: "Mark Twain", SubHeader: "American Author"}, ) view = views.NewCollectionView(container.RenderState(), c, types.CollectionFormatList, nil) + case "table": + columns := []views.TableColumn{ + {Title: "Workspace", Percentage: 40}, + {Title: "Description", Percentage: 35}, + {Title: "Status", Percentage: 25}, + } + + rows := []views.TableRow{ + { + Data: []string{"flow-workspace", "Main development workspace", "Active"}, + Children: []views.TableRow{ + {Data: []string{"docs", "", "5 exec"}}, + {Data: []string{"api", "", "12 exec"}}, + {Data: []string{"frontend", "", "8 exec"}}, + }, + }, + { + Data: []string{"home-lab", "Infrastructure automation", "Inactive"}, + Children: []views.TableRow{ + {Data: []string{"k8s", "", "15 exec"}}, + {Data: []string{"monitoring", "", "6 exec"}}, + }, + }, + { + Data: []string{"personal-tools", "Personal utility scripts", "Active"}, + Children: []views.TableRow{}, + }, + } + + table := views.NewTable(container.RenderState(), columns, rows, views.TableDisplayFull) + + table.SetOnSelect(func(index int) error { + selectedRow := table.GetSelectedRow() + if selectedRow != nil { + container.SetNotice(fmt.Sprintf("Selected: %s", selectedRow.Data()[0]), themes.OutputLevelInfo) + } + return nil + }) + + table.SetOnHover(func(index int) { + selectedRow := table.GetSelectedRow() + if selectedRow != nil { + container.SetState("Current", selectedRow.Data()[0]) + } + }) + + view = views.NewFrameView(table) + case "table-mini": + columns := []views.TableColumn{{Title: "Available Executables", Percentage: 100}} + + rows := []views.TableRow{ + {Data: []string{"build app"}}, + {Data: []string{"test unit"}}, + {Data: []string{"deploy staging"}}, + {Data: []string{"deploy production"}}, + {Data: []string{"clean artifacts"}}, + } + + table := views.NewTable(container.RenderState(), columns, rows, views.TableDisplayMini) + + table.SetOnSelect(func(index int) error { + selectedRow := table.GetSelectedRow() + if selectedRow != nil { + container.SetNotice(fmt.Sprintf("Executing: %s", selectedRow.Data()[0]), themes.OutputLevelInfo) + } + return nil + }) + + view = views.NewFrameView(table) + case "table-mini-multi": + columns := []views.TableColumn{{Title: "Template", Percentage: 60}, {Title: "Type", Percentage: 40}} + + rows := []views.TableRow{ + {Data: []string{"k8s-deployment", "Kubernetes"}}, + {Data: []string{"react-app", "Frontend"}}, + {Data: []string{"go-service", "Backend"}}, + {Data: []string{"terraform-module", "Infrastructure"}}, + } + + table := views.NewTable(container.RenderState(), columns, rows, views.TableDisplayMini) + + table.SetOnSelect(func(index int) error { + selectedRow := table.GetSelectedRow() + if selectedRow != nil { + container.SetNotice(fmt.Sprintf("Selected template: %s (%s)", selectedRow.Data()[0], selectedRow.Data()[1]), themes.OutputLevelInfo) + } + return nil + }) + + view = views.NewFrameView(table) case "form": f, err := views.NewFormView( container.RenderState(), diff --git a/views/table.go b/views/table.go new file mode 100644 index 0000000..703b5b1 --- /dev/null +++ b/views/table.go @@ -0,0 +1,358 @@ +package views + +import ( + "strings" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + + "github.com/flowexec/tuikit/types" +) + +const TableViewType = "table" + +type TableDisplayMode int + +const ( + TableDisplayFull TableDisplayMode = iota + TableDisplayMini +) + +type TableRow struct { + Data []string + Children []TableRow + Expanded bool +} + +type TableColumn struct { + Title string + Percentage int // width as percentage of total table width +} + +type Table struct { + render *types.RenderState + columns []TableColumn + rows []TableRow + displayMode TableDisplayMode + + selectedIndex int + visibleRows []VisibleRow + + OnSelect func(index int) error + OnHover func(index int) + + showBorder bool +} + +type VisibleRow struct { + data []string + isChild bool + parentIdx int + childIdx int + rowIdx int // index in original rows slice (-1 for children) +} + +func (vr *VisibleRow) Data() []string { + return vr.data +} + +func NewTable(render *types.RenderState, columns []TableColumn, rows []TableRow, mode TableDisplayMode) *Table { + t := &Table{ + render: render, + columns: columns, + rows: rows, + displayMode: mode, + showBorder: mode == TableDisplayMini, + } + t.buildVisibleRows() + return t +} + +func (t *Table) Init() tea.Cmd { + return nil +} + +func (t *Table) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case *types.RenderState: + t.render = msg + return t, nil + case tea.KeyMsg: + switch msg.String() { + case "up", "k": + if t.selectedIndex > 0 { + t.selectedIndex-- + if t.OnHover != nil { + t.OnHover(t.selectedIndex) + } + } + case "down", "j": + if t.selectedIndex < len(t.visibleRows)-1 { + t.selectedIndex++ + if t.OnHover != nil { + t.OnHover(t.selectedIndex) + } + } + case "enter": + if t.OnSelect != nil { + return t, func() tea.Msg { + err := t.OnSelect(t.selectedIndex) + if err != nil { + return err + } + return nil + } + } + case " ", "tab": + t.toggleExpansion() + t.buildVisibleRows() + } + } + return t, nil +} + +func (t *Table) View() string { + if t.render == nil || len(t.visibleRows) == 0 { + return "No data" + } + + tableWidth := t.calculateTableWidth() + colWidths := t.calculateColumnWidths(tableWidth) + + var content strings.Builder + + header := t.renderHeader(colWidths) + content.WriteString(header) + content.WriteString("\n") + + for i, row := range t.visibleRows { + rowStr := t.renderRow(row, colWidths, i == t.selectedIndex) + content.WriteString(rowStr) + content.WriteString("\n") + } + + result := content.String() + if t.displayMode == TableDisplayMini && t.showBorder { + return t.renderMiniTable(result, tableWidth) + } + + return result +} + +func (t *Table) HelpMsg() string { + return "↑/↓: navigate • enter: select • space/tab: expand/collapse" +} + +func (t *Table) ShowFooter() bool { + return true +} + +func (t *Table) Type() string { + return TableViewType +} + +func (t *Table) SetOnSelect(callback func(index int) error) { + t.OnSelect = callback +} + +func (t *Table) SetOnHover(callback func(index int)) { + t.OnHover = callback +} + +func (t *Table) SetRows(rows []TableRow) { + t.rows = rows + t.selectedIndex = 0 + t.buildVisibleRows() +} + +func (t *Table) GetSelectedRow() *VisibleRow { + if t.selectedIndex >= 0 && t.selectedIndex < len(t.visibleRows) { + return &t.visibleRows[t.selectedIndex] + } + return nil +} + +func (t *Table) calculateTableWidth() int { + if t.displayMode == TableDisplayMini { + maxWidth := int(float64(t.render.ContentWidth) * 0.66) + minWidth := 30 + if maxWidth < minWidth { + return minWidth + } + return maxWidth + } + return t.render.ContentWidth +} + +func (t *Table) calculateColumnWidths(totalWidth int) []int { + widths := make([]int, len(t.columns)) + usedWidth := 0 + + for i, col := range t.columns { + if i == len(t.columns)-1 { + // last column gets remaining width + widths[i] = totalWidth - usedWidth + } else { + width := (totalWidth * col.Percentage) / 100 + widths[i] = width + usedWidth += width + } + } + + return widths +} + +func (t *Table) renderHeader(colWidths []int) string { + var header string + + style := lipgloss.NewStyle(). + Bold(true). + Border(lipgloss.NormalBorder(), false). + BorderBottom(true). + BorderBottomForeground(t.render.Theme.ColorPalette().BorderColor()). + Foreground(t.render.Theme.ColorPalette().PrimaryColor()) + + for i, col := range t.columns { + title := col.Title + if len(title) > colWidths[i]-1 { + title = title[:colWidths[i]-4] + "..." + } + + cellContent := style.Width(colWidths[i] - 1).Render(title) + header = lipgloss.JoinHorizontal(lipgloss.Right, header, cellContent) + } + + return header +} + +func (t *Table) renderRow(row VisibleRow, colWidths []int, selected bool) string { + var rowStr strings.Builder + + var style lipgloss.Style + if selected { + style = lipgloss.NewStyle(). + Background(t.render.Theme.ColorPalette().PrimaryColor()). + Foreground(t.render.Theme.ColorPalette().GrayColor()).Bold(true) + } else if row.isChild { + style = lipgloss.NewStyle(). + Foreground(t.render.Theme.ColorPalette().TertiaryColor()) + } else { + style = lipgloss.NewStyle(). + Foreground(t.render.Theme.ColorPalette().BodyColor()) + } + + for i, cellData := range row.data { + if i >= len(colWidths) { + break + } + + content := cellData + if i == 0 && !row.isChild && row.rowIdx >= 0 { + if len(t.rows[row.rowIdx].Children) > 0 { + if t.rows[row.rowIdx].Expanded { + content = "◉ " + content + } else { + content = "● " + content + } + } else { + content = "◌ " + content + } + } + + if i == 0 && row.isChild { + if selected { + content = " > " + content + } else { + content = " " + content + } + } + + maxLen := colWidths[i] - 1 + if len(content) > maxLen { + if maxLen > 3 { + content = content[:maxLen-3] + "..." + } else { + content = content[:maxLen] + } + } + + cellContent := style.Width(colWidths[i] - 1).Render(content) + rowStr.WriteString(cellContent) + } + + return rowStr.String() +} + +func (t *Table) renderMiniTable(content string, tableWidth int) string { + leftPadding := (t.render.ContentWidth - tableWidth) / 2 + if leftPadding < 0 { + leftPadding = 0 + } + + borderStyle := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(t.render.Theme.ColorPalette().BorderColor()). + Padding(1). + MarginLeft(leftPadding) + + return borderStyle.Render(content) +} + +func (t *Table) buildVisibleRows() { + t.visibleRows = make([]VisibleRow, 0) + + for i, row := range t.rows { + t.visibleRows = append(t.visibleRows, VisibleRow{ + data: row.Data, + isChild: false, + parentIdx: -1, + childIdx: -1, + rowIdx: i, + }) + + if row.Expanded { + for j, child := range row.Children { + t.visibleRows = append(t.visibleRows, VisibleRow{ + data: child.Data, + isChild: true, + parentIdx: i, + childIdx: j, + rowIdx: -1, + }) + } + } + } + + if t.selectedIndex >= len(t.visibleRows) { + t.selectedIndex = len(t.visibleRows) - 1 + } + if t.selectedIndex < 0 { + t.selectedIndex = 0 + } +} + +func (t *Table) toggleExpansion() { + if t.selectedIndex < 0 || t.selectedIndex >= len(t.visibleRows) { + return + } + + selectedRow := t.visibleRows[t.selectedIndex] + if selectedRow.isChild || selectedRow.rowIdx < 0 { + return + } + + rowIdx := selectedRow.rowIdx + if rowIdx >= len(t.rows) { + return + } + if len(t.rows[rowIdx].Children) == 0 { + return + } + + for i := range t.rows { + if i != rowIdx { + t.rows[i].Expanded = false + } + } + t.rows[rowIdx].Expanded = !t.rows[rowIdx].Expanded +} From a7dc127770cb4cf0e5ff275ba93aee675d0d94e5 Mon Sep 17 00:00:00 2001 From: Jahvon Dockery Date: Tue, 31 Mar 2026 20:49:26 -0400 Subject: [PATCH 4/5] new views --- .claude/settings.local.json | 7 + sample/main.go | 332 ++++++++++++++++++++++-------------- views/detail.go | 191 +++++++++++++++++++++ views/table.go | 238 +++++++++++++++++--------- 4 files changed, 560 insertions(+), 208 deletions(-) create mode 100644 .claude/settings.local.json create mode 100644 views/detail.go diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..b55e75b --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,7 @@ +{ + "permissions": { + "allow": [ + "Bash(go build:*)" + ] + } +} diff --git a/sample/main.go b/sample/main.go index fdf798e..6acf899 100644 --- a/sample/main.go +++ b/sample/main.go @@ -1,4 +1,3 @@ -//nolint:cyclop package main import ( @@ -30,161 +29,236 @@ func main() { panic(err) } + view := buildView(viewType, container) + if err := container.SetView(view); err != nil { + panic(err) + } + container.WaitForExit() +} + +func buildView(viewType string, container *tuikit.Container) tuikit.View { var view tuikit.View switch viewType { case "frame": inner := &sampleTypes.Echo{ - Content: "You are currently viewing a rendered frame. Use the --view flag to switch to a different view.", + Content: "You are currently viewing a rendered frame. " + + "Use the --view flag to switch to a different view.", } view = views.NewFrameView(inner) case "loading": - view = views.NewLoadingView("waiting for the paint to dry...", container.RenderState().Theme) + view = views.NewLoadingView( + "waiting for the paint to dry...", + container.RenderState().Theme, + ) case "error": - view = views.NewErrorView(errors.New("something went wrong - please try again"), container.RenderState().Theme) + view = views.NewErrorView( + errors.New("something went wrong - please try again"), + container.RenderState().Theme, + ) case "markdown": - md := "# Hmmm...\n\n > To be, or not to be, **that is the question**.\n> *William Shakespeare*" + md := "# Hmmm...\n\n > To be, or not to be, " + + "**that is the question**.\n> *William Shakespeare*" view = views.NewMarkdownView(container.RenderState(), md) case "entity": e := &sampleTypes.Thing{Name: "William Shakespeare", Type: "Author"} - view = views.NewEntityView(container.RenderState(), e, types.EntityFormatDocument) - case "collection": - c := sampleTypes.NewThingList("Author", - &types.EntityInfo{ID: "william", Header: "William Shakespeare", SubHeader: "English Playwright"}, - &types.EntityInfo{ID: "jane", Header: "Jane Austen", SubHeader: "English Novelist"}, - &types.EntityInfo{ID: "mark", Header: "Mark Twain", SubHeader: "American Author"}, + view = views.NewEntityView( + container.RenderState(), e, types.EntityFormatDocument, ) - view = views.NewCollectionView(container.RenderState(), c, types.CollectionFormatList, nil) + case "collection": + view = buildCollectionView(container) + case "detail": + view = buildDetailView(container) case "table": - columns := []views.TableColumn{ - {Title: "Workspace", Percentage: 40}, - {Title: "Description", Percentage: 35}, - {Title: "Status", Percentage: 25}, - } + view = buildTableFullView(container) + case "table-mini": + view = buildTableMiniView(container) + case "table-mini-multi": + view = buildTableMiniMultiView(container) + case "form": + view = buildFormView(container) + } + return view +} - rows := []views.TableRow{ - { - Data: []string{"flow-workspace", "Main development workspace", "Active"}, - Children: []views.TableRow{ - {Data: []string{"docs", "", "5 exec"}}, - {Data: []string{"api", "", "12 exec"}}, - {Data: []string{"frontend", "", "8 exec"}}, - }, - }, - { - Data: []string{"home-lab", "Infrastructure automation", "Inactive"}, - Children: []views.TableRow{ - {Data: []string{"k8s", "", "15 exec"}}, - {Data: []string{"monitoring", "", "6 exec"}}, - }, - }, - { - Data: []string{"personal-tools", "Personal utility scripts", "Active"}, - Children: []views.TableRow{}, - }, - } +func buildDetailView(container *tuikit.Container) tuikit.View { + body := `2026-03-30 14:22:01 [INFO] deploy-pipeline read secret DATABASE_URL +2026-03-30 08:15:33 [INFO] api-server read secret DATABASE_URL +2026-03-29 22:00:00 [WARN] rotation check: 75 days remaining +2026-03-28 16:45:12 [INFO] api-server read secret DATABASE_URL +2026-03-28 09:30:00 [INFO] deploy-pipeline read secret DATABASE_URL` - table := views.NewTable(container.RenderState(), columns, rows, views.TableDisplayFull) + return views.NewDetailView( + container.RenderState(), + body, + views.DetailField{Key: "Name", Value: "DATABASE_URL"}, + views.DetailField{Key: "Environment", Value: "production"}, + views.DetailField{Key: "Created", Value: "2026-01-15 10:30:00"}, + views.DetailField{Key: "Rotation", Value: "90 days"}, + ) +} - table.SetOnSelect(func(index int) error { - selectedRow := table.GetSelectedRow() - if selectedRow != nil { - container.SetNotice(fmt.Sprintf("Selected: %s", selectedRow.Data()[0]), themes.OutputLevelInfo) - } - return nil - }) +func buildCollectionView(container *tuikit.Container) tuikit.View { + c := sampleTypes.NewThingList("Author", + &types.EntityInfo{ + ID: "william", Header: "William Shakespeare", + SubHeader: "English Playwright", + }, + &types.EntityInfo{ + ID: "jane", Header: "Jane Austen", + SubHeader: "English Novelist", + }, + &types.EntityInfo{ + ID: "mark", Header: "Mark Twain", + SubHeader: "American Author", + }, + ) + return views.NewCollectionView( + container.RenderState(), c, types.CollectionFormatList, nil, + ) +} - table.SetOnHover(func(index int) { - selectedRow := table.GetSelectedRow() - if selectedRow != nil { - container.SetState("Current", selectedRow.Data()[0]) - } - }) +func buildTableFullView(container *tuikit.Container) tuikit.View { + columns := []views.TableColumn{ + {Title: "Workspace", Percentage: 40}, + {Title: "Description", Percentage: 35}, + {Title: "Status", Percentage: 25}, + } + rows := []views.TableRow{ + { + Data: []string{"flow-workspace", "Main development workspace", "Active"}, + Children: []views.TableRow{ + {Data: []string{"docs", "", "5 exec"}}, + {Data: []string{"api", "", "12 exec"}}, + {Data: []string{"frontend", "", "8 exec"}}, + }, + }, + { + Data: []string{"home-lab", "Infrastructure automation", "Inactive"}, + Children: []views.TableRow{ + {Data: []string{"k8s", "", "15 exec"}}, + {Data: []string{"monitoring", "", "6 exec"}}, + }, + }, + { + Data: []string{"personal-tools", "Personal utility scripts", "Active"}, + Children: []views.TableRow{}, + }, + } - view = views.NewFrameView(table) - case "table-mini": - columns := []views.TableColumn{{Title: "Available Executables", Percentage: 100}} - - rows := []views.TableRow{ - {Data: []string{"build app"}}, - {Data: []string{"test unit"}}, - {Data: []string{"deploy staging"}}, - {Data: []string{"deploy production"}}, - {Data: []string{"clean artifacts"}}, + table := views.NewTable( + container.RenderState(), columns, rows, views.TableDisplayFull, + ) + table.SetOnSelect(func(index int) error { + selectedRow := table.GetSelectedRow() + if selectedRow != nil { + container.SetNotice( + fmt.Sprintf("Selected: %s", selectedRow.Data()[0]), + themes.OutputLevelInfo, + ) } + return nil + }) + table.SetOnHover(func(index int) { + selectedRow := table.GetSelectedRow() + if selectedRow != nil { + container.SetState("Current", selectedRow.Data()[0]) + } + }) + return table +} - table := views.NewTable(container.RenderState(), columns, rows, views.TableDisplayMini) - - table.SetOnSelect(func(index int) error { - selectedRow := table.GetSelectedRow() - if selectedRow != nil { - container.SetNotice(fmt.Sprintf("Executing: %s", selectedRow.Data()[0]), themes.OutputLevelInfo) - } - return nil - }) - - view = views.NewFrameView(table) - case "table-mini-multi": - columns := []views.TableColumn{{Title: "Template", Percentage: 60}, {Title: "Type", Percentage: 40}} +func buildTableMiniView(container *tuikit.Container) tuikit.View { + columns := []views.TableColumn{ + {Title: "Available Executables", Percentage: 100}, + } + rows := []views.TableRow{ + {Data: []string{"build app"}}, + {Data: []string{"test unit"}}, + {Data: []string{"deploy staging"}}, + {Data: []string{"deploy production"}}, + {Data: []string{"clean artifacts"}}, + } - rows := []views.TableRow{ - {Data: []string{"k8s-deployment", "Kubernetes"}}, - {Data: []string{"react-app", "Frontend"}}, - {Data: []string{"go-service", "Backend"}}, - {Data: []string{"terraform-module", "Infrastructure"}}, + table := views.NewTable( + container.RenderState(), columns, rows, views.TableDisplayMini, + ) + table.SetOnSelect(func(index int) error { + selectedRow := table.GetSelectedRow() + if selectedRow != nil { + container.SetNotice( + fmt.Sprintf("Executing: %s", selectedRow.Data()[0]), + themes.OutputLevelInfo, + ) } + return nil + }) + return table +} - table := views.NewTable(container.RenderState(), columns, rows, views.TableDisplayMini) - - table.SetOnSelect(func(index int) error { - selectedRow := table.GetSelectedRow() - if selectedRow != nil { - container.SetNotice(fmt.Sprintf("Selected template: %s (%s)", selectedRow.Data()[0], selectedRow.Data()[1]), themes.OutputLevelInfo) - } - return nil - }) +func buildTableMiniMultiView(container *tuikit.Container) tuikit.View { + columns := []views.TableColumn{ + {Title: "Template", Percentage: 60}, + {Title: "Type", Percentage: 40}, + } + rows := []views.TableRow{ + {Data: []string{"k8s-deployment", "Kubernetes"}}, + {Data: []string{"react-app", "Frontend"}}, + {Data: []string{"go-service", "Backend"}}, + {Data: []string{"terraform-module", "Infrastructure"}}, + } - view = views.NewFrameView(table) - case "form": - f, err := views.NewFormView( - container.RenderState(), - &views.FormField{ - Key: "author", - Title: "Favorite Author", - Required: true, - }, - &views.FormField{ - Key: "color", - Title: "Favorite Color", - Default: "pink", - Required: false, - Description: "hint: it's pink", - }, - &views.FormField{ - Key: "confirm", - Title: "Ready to submit?", - Type: views.PromptTypeConfirm, - }, - ) - if err != nil { - panic(err) + table := views.NewTable( + container.RenderState(), columns, rows, views.TableDisplayMini, + ) + table.SetOnSelect(func(index int) error { + selectedRow := table.GetSelectedRow() + if selectedRow != nil { + msg := fmt.Sprintf( + "Selected template: %s (%s)", + selectedRow.Data()[0], selectedRow.Data()[1], + ) + container.SetNotice(msg, themes.OutputLevelInfo) } - f.Callback = func(v map[string]any) error { - inner := &sampleTypes.Echo{ - Content: fmt.Sprintf( - "Thank you for tell me that your favorite author is %s and your favorite color is %s!", - f.FindByKey("author").Value(), - f.FindByKey("color").Value(), - ), - } - view = views.NewFrameView(inner) - container.SetNextView(view) - return nil - } - view = f - } + return nil + }) + return table +} - if err := container.SetView(view); err != nil { +func buildFormView(container *tuikit.Container) tuikit.View { + f, err := views.NewFormView( + container.RenderState(), + &views.FormField{ + Key: "author", + Title: "Favorite Author", + Required: true, + }, + &views.FormField{ + Key: "color", + Title: "Favorite Color", + Default: "pink", + Required: false, + Description: "hint: it's pink", + }, + &views.FormField{ + Key: "confirm", + Title: "Ready to submit?", + Type: views.PromptTypeConfirm, + }, + ) + if err != nil { panic(err) } - container.WaitForExit() + f.Callback = func(v map[string]any) error { + inner := &sampleTypes.Echo{ + Content: fmt.Sprintf( + "Thank you for tell me that your favorite author "+ + "is %s and your favorite color is %s!", + f.FindByKey("author").Value(), + f.FindByKey("color").Value(), + ), + } + container.SetNextView(views.NewFrameView(inner)) + return nil + } + return f } diff --git a/views/detail.go b/views/detail.go new file mode 100644 index 0000000..659c14b --- /dev/null +++ b/views/detail.go @@ -0,0 +1,191 @@ +package views + +import ( + "strings" + + "github.com/charmbracelet/bubbles/viewport" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + + "github.com/flowexec/tuikit/themes" + "github.com/flowexec/tuikit/types" +) + +const DetailViewType = "detail" + +// DetailField is a key-value pair displayed in the fixed metadata header. +type DetailField struct { + Key string + Value string +} + +// DetailView displays a single item with a fixed metadata header +// (key-value pairs) above a scrollable body box. +type DetailView struct { + metadata []DetailField + body string + metadataHeight int + + viewport viewport.Model + theme themes.Theme + width int + height int +} + +func NewDetailView( + state *types.RenderState, + body string, + metadata ...DetailField, +) *DetailView { + v := &DetailView{ + metadata: metadata, + body: body, + theme: state.Theme, + width: state.ContentWidth, + height: state.ContentHeight, + } + v.syncViewport() + return v +} + +func (v *DetailView) Init() tea.Cmd { + return nil +} + +func (v *DetailView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case *types.RenderState: + v.width = msg.ContentWidth + v.height = msg.ContentHeight + v.theme = msg.Theme + v.syncViewport() + case tea.KeyMsg: + halfPage := max(v.viewport.Height/2, 1) + switch msg.String() { + case "k": + v.viewport.ScrollUp(1) + case "j": + v.viewport.ScrollDown(1) + case "u": + v.viewport.ScrollUp(halfPage) + case "d": + v.viewport.ScrollDown(halfPage) + case "g": + v.viewport.GotoTop() + case "G": + v.viewport.GotoBottom() + } + } + + var cmd tea.Cmd + v.viewport, cmd = v.viewport.Update(msg) + return v, cmd +} + +func (v *DetailView) View() string { + metaStr := v.renderMetadata() + v.viewport.SetContent(v.body) + + var sections []string + if metaStr != "" { + sections = append(sections, metaStr) + } + sections = append(sections, v.renderBodyBox()) + + return lipgloss.JoinVertical(lipgloss.Left, sections...) +} + +func (v *DetailView) HelpMsg() string { + return "↑/↓: scroll • u/d: half-page • g/G: top/bottom" +} + +func (v *DetailView) ShowFooter() bool { + return true +} + +func (v *DetailView) Type() string { + return DetailViewType +} + +func (v *DetailView) SetBody(body string) { + v.body = body +} + +func (v *DetailView) SetMetadata(metadata ...DetailField) { + v.metadata = metadata + v.syncViewport() +} + +func (v *DetailView) syncViewport() { + v.metadataHeight = v.calcMetadataHeight() + // Body box border (2) + padding (2) are chrome around the viewport + bodyChrome := 4 + vpHeight := max(v.height-v.metadataHeight-bodyChrome, 1) + + v.viewport.Width = v.width - 8 // account for border + padding + v.viewport.Height = vpHeight +} + +func (v *DetailView) calcMetadataHeight() int { + if len(v.metadata) == 0 { + return 0 + } + // rows + border top/bottom (2) + margin bottom (1) + return len(v.metadata) + 2 + 1 +} + +func (v *DetailView) renderMetadata() string { + if len(v.metadata) == 0 { + return "" + } + + cp := v.theme.ColorPalette() + maxKeyLen := 0 + for _, f := range v.metadata { + if len(f.Key) > maxKeyLen { + maxKeyLen = len(f.Key) + } + } + + keyStyle := lipgloss.NewStyle(). + Foreground(cp.SecondaryColor()). + Bold(true). + Width(maxKeyLen + 1). + Align(lipgloss.Right) + valStyle := lipgloss.NewStyle(). + Foreground(cp.BodyColor()). + PaddingLeft(1) + sep := lipgloss.NewStyle(). + Foreground(cp.GrayColor()). + Render("│") + + var rows []string + for _, f := range v.metadata { + row := keyStyle.Render(f.Key) + " " + sep + valStyle.Render(f.Value) + rows = append(rows, row) + } + + tableWidth := min(v.width-4, 60) + return lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(cp.BorderColor()). + Padding(0, 1). + Width(tableWidth). + MarginBottom(1). + Render(strings.Join(rows, "\n")) +} + +func (v *DetailView) renderBodyBox() string { + cp := v.theme.ColorPalette() + bodyWidth := v.width - 4 + // viewport.View() returns the scrolled content; wrap it in the box + vpContent := v.viewport.View() + + return lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(cp.BorderColor()). + Foreground(cp.BodyColor()). + Padding(1, 2). + Width(bodyWidth). + Render(vpContent) +} diff --git a/views/table.go b/views/table.go index 703b5b1..e53c39d 100644 --- a/views/table.go +++ b/views/table.go @@ -1,6 +1,7 @@ package views import ( + "fmt" "strings" tea "github.com/charmbracelet/bubbletea" @@ -36,6 +37,7 @@ type Table struct { displayMode TableDisplayMode selectedIndex int + scrollOffset int visibleRows []VisibleRow OnSelect func(index int) error @@ -76,41 +78,53 @@ func (t *Table) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case *types.RenderState: t.render = msg - return t, nil case tea.KeyMsg: - switch msg.String() { - case "up", "k": - if t.selectedIndex > 0 { - t.selectedIndex-- - if t.OnHover != nil { - t.OnHover(t.selectedIndex) - } - } - case "down", "j": - if t.selectedIndex < len(t.visibleRows)-1 { - t.selectedIndex++ - if t.OnHover != nil { - t.OnHover(t.selectedIndex) - } - } - case "enter": - if t.OnSelect != nil { - return t, func() tea.Msg { - err := t.OnSelect(t.selectedIndex) - if err != nil { - return err - } - return nil - } - } - case " ", "tab": - t.toggleExpansion() - t.buildVisibleRows() - } + return t, t.handleKeyMsg(msg) } return t, nil } +func (t *Table) handleKeyMsg(msg tea.KeyMsg) tea.Cmd { + switch msg.String() { + case "up", "k": + t.moveCursor(-1) + case "down", "j": + t.moveCursor(1) + case "enter": + return t.selectRow() + case " ", "tab": + t.toggleExpansion() + t.buildVisibleRows() + t.ensureSelectedVisible() + } + return nil +} + +func (t *Table) moveCursor(delta int) { + next := t.selectedIndex + delta + if next < 0 || next >= len(t.visibleRows) { + return + } + t.selectedIndex = next + t.ensureSelectedVisible() + if t.OnHover != nil { + t.OnHover(t.selectedIndex) + } +} + +func (t *Table) selectRow() tea.Cmd { + if t.OnSelect == nil { + return nil + } + return func() tea.Msg { + err := t.OnSelect(t.selectedIndex) + if err != nil { + return err + } + return nil + } +} + func (t *Table) View() string { if t.render == nil || len(t.visibleRows) == 0 { return "No data" @@ -125,18 +139,47 @@ func (t *Table) View() string { content.WriteString(header) content.WriteString("\n") - for i, row := range t.visibleRows { - rowStr := t.renderRow(row, colWidths, i == t.selectedIndex) + maxRows := t.maxVisibleRows() + start := t.scrollOffset + end := min(start+maxRows, len(t.visibleRows)) + + if start > 0 { + scrollHint := lipgloss.NewStyle(). + Foreground(t.render.Theme.ColorPalette().GrayColor()). + Width(tableWidth).Align(lipgloss.Center). + Render(fmt.Sprintf("↑ %d more", start)) + content.WriteString(scrollHint) + content.WriteString("\n") + } + + for i := start; i < end; i++ { + rowStr := t.renderRow(t.visibleRows[i], colWidths, i == t.selectedIndex) content.WriteString(rowStr) content.WriteString("\n") } + remaining := len(t.visibleRows) - end + if remaining > 0 { + scrollHint := lipgloss.NewStyle(). + Foreground(t.render.Theme.ColorPalette().GrayColor()). + Width(tableWidth).Align(lipgloss.Center). + Render(fmt.Sprintf("↓ %d more", remaining)) + content.WriteString(scrollHint) + content.WriteString("\n") + } + result := content.String() + if t.displayMode == TableDisplayMini && t.showBorder { - return t.renderMiniTable(result, tableWidth) + result = t.renderMiniTable(result, tableWidth) } - return result + // Pad to fill available height so the table occupies the full content area. + rendered := lipgloss.NewStyle(). + Width(t.render.ContentWidth). + Height(t.render.ContentHeight). + Render(result) + return rendered } func (t *Table) HelpMsg() string { @@ -225,55 +268,59 @@ func (t *Table) renderHeader(colWidths []int) string { return header } -func (t *Table) renderRow(row VisibleRow, colWidths []int, selected bool) string { - var rowStr strings.Builder +func (t *Table) rowStyle(row VisibleRow, selected bool) lipgloss.Style { + cp := t.render.Theme.ColorPalette() + switch { + case selected: + return lipgloss.NewStyle(). + Background(cp.PrimaryColor()). + Foreground(cp.GrayColor()).Bold(true) + case row.isChild: + return lipgloss.NewStyle().Foreground(cp.TertiaryColor()) + default: + return lipgloss.NewStyle().Foreground(cp.BodyColor()) + } +} - var style lipgloss.Style - if selected { - style = lipgloss.NewStyle(). - Background(t.render.Theme.ColorPalette().PrimaryColor()). - Foreground(t.render.Theme.ColorPalette().GrayColor()).Bold(true) - } else if row.isChild { - style = lipgloss.NewStyle(). - Foreground(t.render.Theme.ColorPalette().TertiaryColor()) - } else { - style = lipgloss.NewStyle(). - Foreground(t.render.Theme.ColorPalette().BodyColor()) +func (t *Table) cellPrefix(row VisibleRow, colIdx int, selected bool) string { + if colIdx != 0 { + return "" + } + if row.isChild { + if selected { + return " > " + } + return " " + } + if row.rowIdx < 0 { + return "" } + children := t.rows[row.rowIdx].Children + switch { + case len(children) > 0 && t.rows[row.rowIdx].Expanded: + return "◉ " + case len(children) > 0: + return "● " + default: + return "◌ " + } +} + +func (t *Table) renderRow(row VisibleRow, colWidths []int, selected bool) string { + var rowStr strings.Builder + style := t.rowStyle(row, selected) for i, cellData := range row.data { if i >= len(colWidths) { break } - content := cellData - if i == 0 && !row.isChild && row.rowIdx >= 0 { - if len(t.rows[row.rowIdx].Children) > 0 { - if t.rows[row.rowIdx].Expanded { - content = "◉ " + content - } else { - content = "● " + content - } - } else { - content = "◌ " + content - } - } - - if i == 0 && row.isChild { - if selected { - content = " > " + content - } else { - content = " " + content - } - } - + content := t.cellPrefix(row, i, selected) + cellData maxLen := colWidths[i] - 1 - if len(content) > maxLen { - if maxLen > 3 { - content = content[:maxLen-3] + "..." - } else { - content = content[:maxLen] - } + if len(content) > maxLen && maxLen > 3 { + content = content[:maxLen-3] + "..." + } else if len(content) > maxLen { + content = content[:maxLen] } cellContent := style.Width(colWidths[i] - 1).Render(content) @@ -284,20 +331,53 @@ func (t *Table) renderRow(row VisibleRow, colWidths []int, selected bool) string } func (t *Table) renderMiniTable(content string, tableWidth int) string { - leftPadding := (t.render.ContentWidth - tableWidth) / 2 - if leftPadding < 0 { - leftPadding = 0 - } + leftPadding := max((t.render.ContentWidth-tableWidth)/2, 0) + topMargin := 1 + // Border takes 2 lines (top+bottom), padding takes 2 lines (top+bottom) + boxHeight := max(t.render.ContentHeight-topMargin-2-2, 1) borderStyle := lipgloss.NewStyle(). Border(lipgloss.RoundedBorder()). BorderForeground(t.render.Theme.ColorPalette().BorderColor()). Padding(1). - MarginLeft(leftPadding) + MarginLeft(leftPadding). + MarginTop(topMargin). + Height(boxHeight) return borderStyle.Render(content) } +func (t *Table) maxVisibleRows() int { + if t.render == nil || t.render.ContentHeight <= 0 { + return len(t.visibleRows) + } + // Reserve lines for: header (2 lines: title + border), scroll hints (up to 2 lines) + available := t.render.ContentHeight - 2 + if t.displayMode == TableDisplayMini { + // Mini mode border + padding takes extra space + available -= 4 + } + if available < 1 { + available = 1 + } + if available >= len(t.visibleRows) { + return len(t.visibleRows) + } + return available +} + +func (t *Table) ensureSelectedVisible() { + maxRows := t.maxVisibleRows() + if t.selectedIndex < t.scrollOffset { + t.scrollOffset = t.selectedIndex + } else if t.selectedIndex >= t.scrollOffset+maxRows { + t.scrollOffset = t.selectedIndex - maxRows + 1 + } + if t.scrollOffset < 0 { + t.scrollOffset = 0 + } +} + func (t *Table) buildVisibleRows() { t.visibleRows = make([]VisibleRow, 0) From 76264fd19fb0866be5ae4a1e8a5435b0ef849610 Mon Sep 17 00:00:00 2001 From: Jahvon Dockery Date: Tue, 31 Mar 2026 21:01:45 -0400 Subject: [PATCH 5/5] rm --- .claude/settings.local.json | 7 ------- 1 file changed, 7 deletions(-) delete mode 100644 .claude/settings.local.json diff --git a/.claude/settings.local.json b/.claude/settings.local.json deleted file mode 100644 index b55e75b..0000000 --- a/.claude/settings.local.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "permissions": { - "allow": [ - "Bash(go build:*)" - ] - } -}