Add tea
This commit is contained in:
203
cmd/sqv-tea/main.go
Normal file
203
cmd/sqv-tea/main.go
Normal 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
208
cmd/sqv-tea/util.go
Normal 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)
|
||||
Reference in New Issue
Block a user