This commit is contained in:
Pablu
2026-01-16 14:55:04 +01:00
parent e07bb9e496
commit a8c7ad60c3
5 changed files with 475 additions and 1 deletions

203
cmd/sqv-tea/main.go Normal file
View File

@@ -0,0 +1,203 @@
package main
import (
"log"
"github.com/charmbracelet/bubbles/list"
"github.com/charmbracelet/bubbles/table"
"github.com/charmbracelet/bubbles/textarea"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
type mainModel struct {
width, height int
focused int
viewStyle lipgloss.Style
editorStyle lipgloss.Style
pickerStyle lipgloss.Style
table table.Model
editor textarea.Model
picker list.Model
}
var (
defaultStyle = lipgloss.NewStyle().
Align(lipgloss.Center).
BorderStyle(lipgloss.NormalBorder())
)
type item struct {
title, desc string
}
func (i item) Title() string { return i.title }
func (i item) Description() string { return i.desc }
func (i item) FilterValue() string { return i.title }
func newMainModerl() mainModel {
ed := textarea.New()
ed.Placeholder = "Try \"SELECT * FROM ?;\""
ed.ShowLineNumbers = false
ed.Focus()
columns := []table.Column{
{Title: "id", Width: 4},
{Title: "name", Width: 4},
{Title: "family_name", Width: 4},
}
rows := []table.Row{
{"0", "Conrad", "Adenauer"},
{"1", "Anna", "Aachen"},
{"2", "Karli", "Columbus"},
{"3", "Max", "Mustermann"},
{"4", "Bernd", "Brot"},
}
ta := table.New(
table.WithColumns(columns),
table.WithRows(rows),
)
items := []list.Item{
item{
title: "user",
desc: "users table",
},
item{
title: "job",
desc: "jobs table",
},
item{
title: "user_has_job",
desc: "user has a job",
},
}
li := list.New(items, list.NewDefaultDelegate(), 0, 0)
li.Title = "Table Picker"
return mainModel{
focused: 0,
viewStyle: defaultStyle,
editorStyle: defaultStyle,
pickerStyle: defaultStyle.BorderStyle(lipgloss.RoundedBorder()).BorderForeground(lipgloss.Color("67")),
editor: ed,
table: ta,
picker: li,
}
}
func (m mainModel) Init() tea.Cmd {
return nil
}
func (m mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var (
edCmd tea.Cmd
taCmd tea.Cmd
liCmd tea.Cmd
)
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.String() {
case "ctrl+c", "q":
return m, tea.Quit
case "tab":
if m.table.Focused() {
m.table.Blur()
m.editor.Focus()
} else {
m.editor.Blur()
m.table.Focus()
}
case "ctrl+e":
if m.focused == 1 {
m.focused = 0
} else {
m.focused = 1
}
}
case tea.WindowSizeMsg:
m = m.updateStyles(msg.Width, msg.Height)
}
m.editor, edCmd = m.editor.Update(msg)
m.table, taCmd = m.table.Update(msg)
if m.focused == 1 {
m.picker, liCmd = m.picker.Update(msg)
}
return m, tea.Batch(edCmd, taCmd, liCmd)
}
func (m mainModel) updateStyles(width, height int) mainModel {
h, v := defaultStyle.GetFrameSize()
topHeight := (height * 3 / 4) - h
editorHeight := (height * 1 / 4) - h
m.editorStyle = defaultStyle.
Width(width - v).
Height(editorHeight)
m.viewStyle = defaultStyle.
Width(width - v).
Height(topHeight)
m.editor.SetWidth(m.editorStyle.GetWidth())
m.editor.SetHeight(m.editorStyle.GetHeight())
m.table.SetWidth(m.viewStyle.GetWidth())
m.table.SetHeight(m.viewStyle.GetHeight())
columns := m.table.Columns()
colLen := len(columns)
colWidth := m.table.Width() / colLen
for i := range columns {
columns[i].Width = colWidth
}
m.table.SetColumns(columns)
m.pickerStyle = m.pickerStyle.
Width((width * 7 / 10) - v).
Height((height * 7 / 10) - h)
m.picker.SetSize(m.pickerStyle.GetWidth(), m.pickerStyle.GetHeight())
m.width = width
m.height = height
return m
}
func (m mainModel) View() string {
view := m.viewStyle.
Render(m.table.View())
editor := m.editorStyle.
Render(m.editor.View())
main := lipgloss.JoinVertical(lipgloss.Top, view, editor)
_ = main
if m.focused == 1 {
x := (m.width / 2) - m.picker.Width()/2
y := (m.height / 2) - m.picker.Height()/2
return PlaceOverlay(x, y, m.pickerStyle.Render(m.picker.View()), main, false)
}
return main
}
func main() {
p := tea.NewProgram(newMainModerl(), tea.WithAltScreen())
if _, err := p.Run(); err != nil {
log.Fatalf("could not start program: %v\n", err)
}
}

208
cmd/sqv-tea/util.go Normal file
View File

@@ -0,0 +1,208 @@
package main
import (
"bytes"
"strings"
"github.com/charmbracelet/lipgloss"
"github.com/mattn/go-runewidth"
"github.com/muesli/ansi"
"github.com/muesli/reflow/truncate"
"github.com/muesli/termenv"
)
// Most of this code is borrowed from
// https://github.com/charmbracelet/lipgloss/pull/102
// as well as the lipgloss library, with some modification for what I needed.
// Split a string into lines, additionally returning the size of the widest
// line.
func getLines(s string) (lines []string, widest int) {
lines = strings.Split(s, "\n")
for _, l := range lines {
w := ansi.PrintableRuneWidth(l)
if widest < w {
widest = w
}
}
return lines, widest
}
// PlaceOverlay places fg on top of bg.
func PlaceOverlay(
x, y int,
fg, bg string,
shadow bool, opts ...WhitespaceOption,
) string {
fgLines, fgWidth := getLines(fg)
bgLines, bgWidth := getLines(bg)
bgHeight := len(bgLines)
fgHeight := len(fgLines)
if shadow {
var shadowbg string = ""
shadowchar := lipgloss.NewStyle().
Foreground(lipgloss.Color("#333333")).
Render("░")
for i := 0; i <= fgHeight; i++ {
if i == 0 {
shadowbg += " " + strings.Repeat(" ", fgWidth) + "\n"
} else {
shadowbg += " " + strings.Repeat(shadowchar, fgWidth) + "\n"
}
}
fg = PlaceOverlay(0, 0, fg, shadowbg, false, opts...)
fgLines, fgWidth = getLines(fg)
fgHeight = len(fgLines)
}
if fgWidth >= bgWidth && fgHeight >= bgHeight {
// FIXME: return fg or bg?
return fg
}
// TODO: allow placement outside of the bg box?
x = clamp(x, 0, bgWidth-fgWidth)
y = clamp(y, 0, bgHeight-fgHeight)
ws := &whitespace{}
for _, opt := range opts {
opt(ws)
}
var b strings.Builder
for i, bgLine := range bgLines {
if i > 0 {
b.WriteByte('\n')
}
if i < y || i >= y+fgHeight {
b.WriteString(bgLine)
continue
}
pos := 0
if x > 0 {
left := truncate.String(bgLine, uint(x))
pos = ansi.PrintableRuneWidth(left)
b.WriteString(left)
if pos < x {
b.WriteString(ws.render(x - pos))
pos = x
}
}
fgLine := fgLines[i-y]
b.WriteString(fgLine)
pos += ansi.PrintableRuneWidth(fgLine)
right := cutLeft(bgLine, pos)
bgWidth := ansi.PrintableRuneWidth(bgLine)
rightWidth := ansi.PrintableRuneWidth(right)
if rightWidth <= bgWidth-pos {
b.WriteString(ws.render(bgWidth - rightWidth - pos))
}
b.WriteString(right)
}
return b.String()
}
// cutLeft cuts printable characters from the left.
// This function is heavily based on muesli's ansi and truncate packages.
func cutLeft(s string, cutWidth int) string {
var (
pos int
isAnsi bool
ab bytes.Buffer
b bytes.Buffer
)
for _, c := range s {
var w int
if c == ansi.Marker || isAnsi {
isAnsi = true
ab.WriteRune(c)
if ansi.IsTerminator(c) {
isAnsi = false
if bytes.HasSuffix(ab.Bytes(), []byte("[0m")) {
ab.Reset()
}
}
} else {
w = runewidth.RuneWidth(c)
}
if pos >= cutWidth {
if b.Len() == 0 {
if ab.Len() > 0 {
b.Write(ab.Bytes())
}
if pos-cutWidth > 1 {
b.WriteByte(' ')
continue
}
}
b.WriteRune(c)
}
pos += w
}
return b.String()
}
func clamp(v, lower, upper int) int {
return min(max(v, lower), upper)
}
func max(a, b int) int {
if a > b {
return a
}
return b
}
func min(a, b int) int {
if a < b {
return a
}
return b
}
type whitespace struct {
style termenv.Style
chars string
}
// Render whitespaces.
func (w whitespace) render(width int) string {
if w.chars == "" {
w.chars = " "
}
r := []rune(w.chars)
j := 0
b := strings.Builder{}
// Cycle through runes and print them into the whitespace.
for i := 0; i < width; {
b.WriteRune(r[j])
j++
if j >= len(r) {
j = 0
}
i += ansi.PrintableRuneWidth(string(r[j]))
}
// Fill any extra gaps white spaces. This might be necessary if any runes
// are more than one cell wide, which could leave a one-rune gap.
short := width - ansi.PrintableRuneWidth(b.String())
if short > 0 {
b.WriteString(strings.Repeat(" ", short))
}
return w.style.Styled(b.String())
}
// WhitespaceOption sets a styling rule for rendering whitespace.
type WhitespaceOption func(*whitespace)