From b787ef92df3f859b033aa8cded6b1f6dbc7c2be0 Mon Sep 17 00:00:00 2001 From: tjp Date: Fri, 16 Feb 2024 09:44:52 -0700 Subject: [PATCH] lipgloss styles --- actions.go | 4 +- command.go | 4 +- handlers.go | 52 ++++++++------ main.go | 30 ++++---- state.go | 14 +++- tls.go | 3 +- tui.go | 197 +++++++++++++++++++++++++++++++++++++++------------- 7 files changed, 212 insertions(+), 92 deletions(-) diff --git a/actions.go b/actions.go index c80445b..656ab99 100644 --- a/actions.go +++ b/actions.go @@ -252,7 +252,7 @@ func fetch(state *BrowserState, u string, tlsConf *tls.Config) (*sliderule.Respo var tofuErr *TOFUViolation if errors.As(err, &tofuErr) { - writeError(err.Error()) + state.Printer.PrintError(err.Error()) state.Readline.SetPrompt("Trust new certificate instead (y/n)? [n] ") line, err := state.Readline.Readline() if err != nil { @@ -284,7 +284,7 @@ func upload(state *BrowserState, u string, body io.Reader, tlsConf *tls.Config) response, err := sliderule.NewClient(tlsConf).Upload(ctx, u, body) var tofuErr *TOFUViolation if errors.As(err, &tofuErr) { - writeError(err.Error()) + state.Printer.PrintError(err.Error()) state.Readline.SetPrompt("Trust new certificate instead (y/n)? [n] ") line, err := state.Readline.Readline() if err != nil { diff --git a/command.go b/command.go index e231c7a..1dfe13f 100644 --- a/command.go +++ b/command.go @@ -20,7 +20,7 @@ type Command struct { func ParseCommand(line string) (*Command, error) { line = strings.TrimSpace(line) if line == "" { - return &Command{Name: "print"}, nil + return &Command{Name: "default"}, nil } cmd, rest, _ := strings.Cut(line, " ") @@ -404,6 +404,8 @@ func RunCommand(cmd *Command, state *BrowserState) error { return Outline(state) case "pipe": return Pipe(state, cmd.Args[0]) + case "default": + return HandleResource(state) case "print": return Print(state) case "links": diff --git a/handlers.go b/handlers.go index 599d53b..c87d82b 100644 --- a/handlers.go +++ b/handlers.go @@ -8,6 +8,7 @@ import ( "net/url" "strings" + "github.com/charmbracelet/lipgloss" "tildegit.org/tjp/sliderule" "tildegit.org/tjp/sliderule/gemini" "tildegit.org/tjp/sliderule/gemini/gemtext" @@ -121,7 +122,7 @@ func parseGophermapDoc(body []byte, softWrap int) (string, []Link, error) { Text: item.Display, Target: fmtGopherURL(item.Type, item.Selector, item.Hostname, item.Port), }) - if _, err := b.WriteString(fmt.Sprintf("[%d]%s %s%s%s\n", i, padding(i, width), linkStyle, item.Display, ansiClear)); err != nil { + if _, err := b.WriteString(fmt.Sprintf("[%d]%s %s\n", i, padding(i, width), linkStyle.Render(item.Display))); err != nil { return "", nil, err } i += 1 @@ -131,16 +132,15 @@ func parseGophermapDoc(body []byte, softWrap int) (string, []Link, error) { return b.String(), l, nil } -const ( - ansiClear = "\x1b[0m" - linkStyle = "\x1b[38;5;33m" - promptStyle = "\x1b[38;5;39m" - quoteStyle = "\x1b[38;5;208m\x1b[3m" - rawStyle = "\x1b[38;5;249m" - h1Style = "\x1b[38;5;154m\x1b[1m\x1b[4m" - h2Style = "\x1b[38;5;50m\x1b[4m" - h3Style = "\x1b[38;5;6m\x1b[4m" - listStyle = "\x1b[38;5;3m" +var ( + linkStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("33")) + promptStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("39")) + quoteStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("208")).Italic(true) + rawStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("249")) + h1Style = lipgloss.NewStyle().Foreground(lipgloss.Color("154")).Bold(true).Underline(true) + h2Style = lipgloss.NewStyle().Foreground(lipgloss.Color("50")).Underline(true) + h3Style = lipgloss.NewStyle().Foreground(lipgloss.Color("6")).Underline(true) + listStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("3")) ) func parseGemtextDoc(body []byte, softWrap int) (string, []Link, error) { @@ -183,22 +183,30 @@ func parseGemtextDoc(body []byte, softWrap int) (string, []Link, error) { if len(label) == 0 { label = ll.URL() } - if _, err := b.WriteString(fmt.Sprintf("[%d]%s %s%s%s\n", i, padding(i, width), linkStyle, label, ansiClear)); err != nil { - return "", nil, err + for j, line := range fold(label, softWrap) { + var prefix string + if j == 0 { + prefix = fmt.Sprintf("[%d]%s ", i, padding(i, width)) + } else { + prefix = strings.Repeat(" ", width+3) + } + if _, err := fmt.Fprintf(&b, "%s%s\n", prefix, linkStyle.Render(line)); err != nil { + return "", nil, err + } } i += 1 case gemtext.LineTypeQuote: q := item.(gemtext.QuoteLine) for _, line := range fold(q.Body(), softWrap-1) { line = strings.TrimSpace(line) - if _, err := b.WriteString(textpad + "> " + quoteStyle + line + ansiClear + "\n"); err != nil { + if _, err := b.WriteString(textpad + "> " + quoteStyle.Render(line) + "\n"); err != nil { return "", nil, err } } case gemtext.LineTypePreformatToggle: case gemtext.LineTypePreformattedText: for _, line := range fold(item.String(), softWrap) { - if _, err := b.WriteString(textpad + rawStyle + line + ansiClear + "\n"); err != nil { + if _, err := b.WriteString(textpad + rawStyle.Render(line) + "\n"); err != nil { return "", nil, err } } @@ -210,19 +218,19 @@ func parseGemtextDoc(body []byte, softWrap int) (string, []Link, error) { fallthrough case gemtext.LineTypeHeading1: hLevel += 1 - var color string + var style lipgloss.Style switch hLevel { case 1: - color = h1Style + style = h1Style case 2: - color = h2Style + style = h2Style case 3: - color = h3Style + style = h3Style } for _, line := range fold(item.String(), softWrap) { line = strings.TrimRight(line, "\r\n") - if _, err := b.WriteString(textpad + color + line + ansiClear + "\n"); err != nil { + if _, err := b.WriteString(textpad + style.Render(line) + "\n"); err != nil { return "", nil, err } } @@ -233,7 +241,7 @@ func parseGemtextDoc(body []byte, softWrap int) (string, []Link, error) { if i == 0 { lpad = "* " } - if _, err := b.WriteString(textpad + listStyle + lpad + line + ansiClear + "\n"); err != nil { + if _, err := b.WriteString(textpad + lpad + listStyle.Render(line) + "\n"); err != nil { return "", nil, err } } @@ -307,5 +315,5 @@ func numberWidth(i int) int { } func padding(num int, width int) string { - return string(bytes.Repeat([]byte{' '}, width-numberWidth(num))) + return strings.Repeat(" ", width-numberWidth(num)) } diff --git a/main.go b/main.go index 695a8fc..715bc57 100644 --- a/main.go +++ b/main.go @@ -2,19 +2,18 @@ package main import ( "flag" - "fmt" "io" "log" - "os" "strings" "github.com/chzyer/readline" ) var ( - cmdMode = flag.String("c", "", "") - helpMode = flag.Bool("h", false, "") - quietMode = flag.Bool("q", false, "") + cmdMode = flag.String("c", "", "") + helpMode = flag.Bool("h", false, "") + quietMode = flag.Bool("q", false, "") + promptMode = flag.Bool("p", false, "") ) func main() { @@ -44,8 +43,11 @@ func main() { state.Quiet = true } - runInteractivePrompt(state, flag.Args()) - // runTUI(state, flag.Args()) + if *promptMode { + runInteractivePrompt(state, flag.Args()) + } else { + runTUI(state, flag.Args()) + } } func buildReadline(prompt string, conf *Config) (*readline.Instance, error) { @@ -91,7 +93,7 @@ func buildInitialState() (*BrowserState, error) { } state.Identities = idents - rl, err := buildReadline(Prompt, conf) + rl, err := buildReadline(prompt(), conf) if err != nil { log.Fatal(err) } @@ -105,12 +107,12 @@ func runInteractivePrompt(state *BrowserState, args []string) { if len(args) > 0 { if err := Go(state, args[0]); err != nil { - writeError(err.Error()) + state.Printer.PrintError(err.Error()) } } for { - state.Readline.SetPrompt(Prompt) + state.Readline.SetPrompt(prompt()) line, err := state.Readline.Readline() if err == io.EOF { break @@ -120,7 +122,7 @@ func runInteractivePrompt(state *BrowserState, args []string) { } if err := handleCmdLine(state, line); err != nil { - writeError(err.Error()) + state.Printer.PrintError(err.Error()) } } } @@ -136,8 +138,10 @@ func handleCmdLine(state *BrowserState, line string) error { return nil } -const Prompt = promptStyle + "X-1" + ansiClear + "> " +func prompt() string { + return promptStyle.Render("X-1") + "> " +} func writeError(msg string) { - fmt.Fprintf(os.Stdout, "\x1b[31m%s\x1b[0m\n", msg) + _ = PromptPrinter{}.PrintError(msg) } diff --git a/state.go b/state.go index 918346f..5c3aa5c 100644 --- a/state.go +++ b/state.go @@ -3,10 +3,12 @@ package main import ( "bytes" "errors" + "fmt" "net/url" "os" "os/exec" + "github.com/charmbracelet/lipgloss" "github.com/chzyer/readline" ) @@ -69,16 +71,17 @@ func NewBrowserState(conf *Config) *BrowserState { type Printer interface { PrintModal(*BrowserState, []byte) error PrintPage(*BrowserState, string) error + PrintError(string) error } type PromptPrinter struct{} -func (_ PromptPrinter) PrintModal(state *BrowserState, contents []byte) error { +func (PromptPrinter) PrintModal(state *BrowserState, contents []byte) error { _, err := os.Stdout.Write(contents) return err } -func (_ PromptPrinter) PrintPage(state *BrowserState, body string) error { +func (PromptPrinter) PrintPage(state *BrowserState, body string) error { if state.Quiet { return nil } @@ -105,3 +108,10 @@ func (_ PromptPrinter) PrintPage(state *BrowserState, body string) error { return errors.New("invalid 'pager' value in configuration") } } + +var promptErrorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("196")).Bold(true) + +func (PromptPrinter) PrintError(msg string) error { + _, err := fmt.Println(promptErrorStyle.Render(msg)) + return err +} diff --git a/tls.go b/tls.go index d4452f2..b30a91b 100644 --- a/tls.go +++ b/tls.go @@ -99,8 +99,7 @@ func createIdentity(state *BrowserState, name string) (*tls.Config, error) { } } - snLimit := new(big.Int).Lsh(big.NewInt(1), 128) - serialNumber, err := rand.Int(rand.Reader, snLimit) + serialNumber, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128)) if err != nil { return nil, err } diff --git a/tui.go b/tui.go index c131110..48bc2d9 100644 --- a/tui.go +++ b/tui.go @@ -1,80 +1,177 @@ package main import ( + "fmt" "os" + "strings" - tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/bubbles/textinput" "github.com/charmbracelet/bubbles/viewport" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" ) -type TUIModel struct { - State *BrowserState - Viewport viewport.Model - inited bool -} - -func NewTUIModel(state *BrowserState) *TUIModel { - return &TUIModel{State: state} -} - -func (model *TUIModel) Init() tea.Cmd { - return nil -} - -func (model *TUIModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - switch msg := msg.(type) { - case tea.KeyMsg: - switch msg.String() { - case "ctrl+c", "q": - return model, tea.Quit - case "g": - model.Viewport.GotoTop() - return model, nil - case "G": - model.Viewport.GotoBottom() - return model, nil - } - case tea.WindowSizeMsg: - model.inited = true - model.Viewport.Width = msg.Width - model.Viewport.Height = msg.Height - 1 - } - - var cmd tea.Cmd - model.Viewport, cmd = model.Viewport.Update(msg) - - return model, cmd -} - -func (model *TUIModel) View() string { - return model.Viewport.View() -} - func runTUI(state *BrowserState, args []string) { - model := NewTUIModel(state) + model := NewMainModel(state) state.Printer = (*TUIPrinter)(model) if len(args) > 0 { if err := Go(state, args[0]); err != nil { - writeError(err.Error()) + state.Printer.PrintError(err.Error()) } } p := tea.NewProgram(model, tea.WithAltScreen()) if _, err := p.Run(); err != nil { - writeError(err.Error()) + state.Printer.PrintError(err.Error()) os.Exit(1) } } -type TUIPrinter TUIModel +var ( + hdrStyle = lipgloss.NewStyle().Background(lipgloss.Color("56")).Bold(true) + ftrStyle = lipgloss.NewStyle().Background(lipgloss.Color("56")) + errStyle = lipgloss.NewStyle().Background(lipgloss.Color("196")).Bold(true) +) + +type MainModel struct { + State *BrowserState + Viewport viewport.Model + Prompt *textinput.Model + ErrorMsg string +} + +func NewMainModel(state *BrowserState) *MainModel { + return &MainModel{ + State: state, + Viewport: viewport.Model{ + HighPerformanceRendering: true, + YPosition: 2, + }, + } +} + +func (model *MainModel) Init() tea.Cmd { + return nil +} + +func (model *MainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + if model.Prompt != nil { + return model.updatePrompt(msg) + } + + m := model + var cmds []tea.Cmd + + switch msg := msg.(type) { + case tea.KeyMsg: + model.ErrorMsg = "" + + switch msg.String() { + case "ctrl+c", "q": + cmds = append(cmds, tea.Quit) + case "ctrl+l": + cmds = append(cmds, viewport.Sync(model.Viewport)) + case "g": + lines := model.Viewport.GotoTop() + cmds = append(cmds, viewport.ViewUp(model.Viewport, lines)) + case "G": + lines := model.Viewport.GotoBottom() + cmds = append(cmds, viewport.ViewDown(model.Viewport, lines)) + case ":": + p := textinput.New() + model.Prompt = &p + cmds = append(cmds, p.Focus()) + } + case tea.WindowSizeMsg: + model.Viewport.Width = msg.Width + model.Viewport.Height = msg.Height - 2 + hdrStyle = hdrStyle.Width(msg.Width) + cmds = append(cmds, viewport.Sync(model.Viewport)) + } + + var cmd tea.Cmd + vp, cmd := model.Viewport.Update(msg) + model.Viewport = vp + cmds = append(cmds, cmd) + + return m, tea.Batch(cmds...) +} + +func (model *MainModel) updatePrompt(msg tea.Msg) (tea.Model, tea.Cmd) { + if keymsg, ok := msg.(tea.KeyMsg); ok { + model.ErrorMsg = "" + + switch keymsg.String() { + case "enter": + cmd, err := ParseCommand(model.Prompt.Value()) + model.Prompt = nil + if err != nil { + model.State.Printer.PrintError(err.Error()) + return model, nil + } + if err := RunCommand(cmd, model.State); err != nil { + model.State.Printer.PrintError(err.Error()) + return model, nil + } + return model, viewport.Sync(model.Viewport) + } + } + + p, cmd := model.Prompt.Update(msg) + model.Prompt = &p + return model, cmd +} + +func (model *MainModel) View() string { + return model.viewHeader() + "\n" + model.Viewport.View() + "\n" + model.viewFooter() +} + +func (model *MainModel) viewHeader() string { + hdrLine := " Welcome to X-1" + if model.State.Url != nil { + hdrLine = " " + model.State.Url.String() + } + return hdrStyle.Render(hdrLine) +} + +func (model *MainModel) viewFooter() string { + if model.ErrorMsg != "" { + return errStyle.Render(model.ErrorMsg) + } + + var footerLine string + if model.Prompt != nil { + footerLine = ftrStyle.Width(hdrStyle.GetWidth()).Render(model.Prompt.View()) + } else { + footerLine = ftrStyle.Render(" ") + ftrStyle.Copy().Italic(true).Render(model.State.DocType) + pct := ftrStyle.Render(fmt.Sprintf("%3.f%% ", model.Viewport.ScrollPercent()*100)) + footerLine += ftrStyle.Render(strings.Repeat(" ", max(0, model.Viewport.Width-lipgloss.Width(footerLine)-lipgloss.Width(pct)))) + footerLine += pct + } + return footerLine +} + +func max(a, b int) int { + if a < b { + return b + } + return a +} + +type TUIPrinter MainModel func (p *TUIPrinter) PrintModal(state *BrowserState, contents []byte) error { - (*TUIModel)(p).Viewport.SetContent(string(contents)) + (*MainModel)(p).Viewport.SetContent(strings.TrimSuffix(string(contents), "\n")) return nil } func (p *TUIPrinter) PrintPage(state *BrowserState, body string) error { - (*TUIModel)(p).Viewport.SetContent(body) + (*MainModel)(p).Viewport.SetContent(strings.TrimSuffix(body, "\n")) + return nil +} + +func (p *TUIPrinter) PrintError(msg string) error { + (*MainModel)(p).ErrorMsg = msg return nil }