H4X3MEXPAMD7EFT4SH5QR4SVCH5WBBALEKOKRNGT4GNNC22LCXOQC
GIA6TLIRNBTNNDBC6KBNY6U4OKNSIIEEZAW47AELL3I67N6V5CIQC
BOQ7THDGYO62LXAK73I6YEIBPMDEQZ7J5JU4EQZMJVW2WZVK4VHAC
JNU4ZGY5RAJSPIPDS63KLPQM4AXRYCHF72BRAZAVZRCV4FK7TGVAC
BNYCTEJNONO7ECZMUSRAMBIZ6H344H2ZGSPAUCTPK2TVFNYPLGYAC
75KUI4W7LOCQRLGVL76AJ7NCDEUZD2RIJQMWR7XHEYU2RNHOHWQAC
XRWBP4HXCO44YAXOZJPKYC2CLRPOXUPPS7TIZXEDFIMVHJ7GI7FAC
WSHSUKJNQS5LEC6DEY52MUH4LQHAMQFSNYNACZTM6EK357TDB3FAC
L23646QEMZPB366O52BQACSTIXR5K5ARMCTCT3IWL7JIMSFMQB4AC
BCLBZMCTFR2ESWVVMGOPM7QSUD5TXV6ADTGBB5PHL7CPIYQIIY6AC
ONTPUCESSETHTD6BIP6M5LEO3KMTNNCTPTV3SAH2HOQW5H2Q2DQQC
MRP2IYLEDZ74P6364EIGB6FS6LP6SSCGZZIDBODPXIRGPB7CPWHAC
COGZHHYVN54XZHQ7RNDUW3IESENXC4WQD5EKKJDHBZTGMI45HASQC
SMK755RWW5HQAETGZ37IP7WUL7P4AC3VXGLWO6TINE23HJHFMLRAC
package main
import (
"context"
"fmt"
db "github.com/Asfolny/protastim/internal/database"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/bubbles/spinner"
)
type taskModel struct {
id int64
config *config
data *db.Task
modal tea.Model
err error
loading bool
editing bool
spinner spinner.Model
}
func newTask(config *config, id int64) taskModel {
m := taskModel{
id: id,
config: config,
loading: true,
spinner: config.newSpinner(),
}
return m
}
func (m taskModel) fetchTask() tea.Msg {
row, err := m.config.queries.GetTask(context.Background(), m.id)
if err != nil {
return errMsg{err}
}
return row
}
func (m taskModel) Init() tea.Cmd {
return tea.Batch(m.fetchTask, m.spinner.Tick)
}
func (m taskModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case db.Task:
m.data = &msg
m.loading = false
return m, nil
case errMsg:
m.err = msg
m.loading = false
return m, tea.Quit
case spinner.TickMsg:
var cmd tea.Cmd
m.spinner, cmd = m.spinner.Update(msg)
return m, cmd
case tea.KeyMsg:
switch msg.String() {
case "ctrl+n":
m.editing = true
return m, m.modal.Init()
}
}
if m.editing {
var cmd tea.Cmd
m.modal, cmd = m.modal.Update(msg)
return m, cmd
}
return m, nil
}
func (m taskModel) View() string {
if m.loading {
return fmt.Sprintf("\n%s Loading\n\n", m.spinner.View())
}
if m.err != nil {
return fmt.Sprintf("\nFailed fetching task: %v\n\n", m.err)
}
if m.editing {
return m.modal.View()
}
return fmt.Sprintf(
"Name: %s\nDescription:\n%s\nStatus: %s\n",
m.data.Name,
m.data.Description.String,
m.data.Status,
)
}
m.modal = newTaskEdit(m.config, nil, "")
package main
import (
"context"
"fmt"
db "github.com/Asfolny/protastim/internal/database"
tea "github.com/charmbracelet/bubbletea"
)
type projectModel struct {
}
func newProject(config *config, id int64) projectModel {
m := projectModel{
}
t := table.New(
table.WithColumns([]table.Column{
{Title: "ID", Width: 4},
{Title: "Name"},
{Title: "Status", Width: 8},
}),
table.WithFocused(true),
)
ts := table.DefaultStyles()
ts.Header = ts.Header.
BorderStyle(lipgloss.NormalBorder()).
BorderForeground(lipgloss.Color("240")).
BorderBottom(true).
Bold(false)
ts.Selected = ts.Selected.
Foreground(lipgloss.Color("229")).
Background(lipgloss.Color("57")).
Bold(false)
t.SetStyles(ts)
m.taskList = t
return m
}
func (m projectModel) fetchProject() tea.Msg {
if err != nil {
return errMsg{err}
}
}
func (m projectModel) Init() tea.Cmd {
}
func (m projectModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case rows:
m.loadingTasks = false
m.taskList.SetRows(msg.data)
return m, nil
case db.Project:
m.data = &msg
return m, nil
case errMsg:
m.err = msg
return m, tea.Quit
case spinner.TickMsg:
var cmd tea.Cmd
m.spinner, cmd = m.spinner.Update(msg)
return m, cmd
case tea.KeyMsg:
switch msg.String() {
case "tab":
switch m.focus {
case focusDesc:
m.focus = focusTasks
m.taskList.Focus()
case focusTasks:
m.focus = focusDesc
m.taskList.Blur()
}
case "enter":
if m.focus == focusTasks {
id, _ := strconv.ParseInt(m.taskList.SelectedRow()[0], 10, 64)
return m, changeView(newTask(m.config, id))
}
case "ctrl+n":
}
}
}
}
func (m projectModel) View() string {
return fmt.Sprintf("\n%s Loading\n\n", m.spinner.View())
}
if m.err != nil {
}
}
m.taskList.SetColumns(cols)
m.taskList.SetHeight(desc.Height)
m.taskList.SetWidth(desc.Width)
}
descOut := lg.NewStyle().BorderStyle(lipgloss.NormalBorder()).Render(desc.View())
table := lg.NewStyle().BorderStyle(lipgloss.NormalBorder()).Render(m.taskList.View())
sb.WriteString(title)
sb.WriteString(status)
sb.WriteString(lipgloss.JoinHorizontal(lipgloss.Bottom, descOut, table))
return sb.String()
var sb strings.Builder
lg := m.config.lg
titleStyle := lg.NewStyle().
AlignHorizontal(lipgloss.Center).
Width(m.config.getInnerWidth()).
MarginTop(2).
MarginBottom(1).
Bold(true)
title := titleStyle.Render(m.data.Name + "\n")
status := m.config.styles.Base.Render(fmt.Sprintf("Status: %s\n", m.data.Status))
// TODO more task/desc layouts
desc := m.descVw
desc.SetContent(m.data.Description.String)
desc.Width = (m.config.getInnerWidth() / 2) - 4
desc.Height = m.config.getInnerHeight() - lipgloss.Height(title) - lipgloss.Height(status)
cols := m.taskList.Columns()
for i, col := range cols {
if col.Title == "Name" {
col.Width = desc.Width - 4 - 8
cols[i] = col
}
return fmt.Sprintf("\nFailed fetching project or tasks: %v\n\n", m.err)
if m.loadingProject {
return m, cmd
var cmd tea.Cmd
switch m.focus {
case focusDesc:
m.descVw, cmd = m.descVw.Update(msg)
case focusTasks:
m.taskList, cmd = m.taskList.Update(msg)
return m, changeView(newProjectEdit(m.config, nil))
case "ctrl+e":
return m, changeView(newProjectEdit(m.config, m.data))
case "ctrl+t":
return m, changeView(newTaskEdit(m.config, nil, m.data.Name))
m.loadingProject = false
m.loadingTasks = false
m.loadingProject = false
m.descVw.SetContent(m.data.Description.String)
return tea.Batch(m.fetchProject, m.fetchTasks, m.spinner.Tick)
tableRows := make([]table.Row, len(tasks), len(tasks))
for i, e := range tasks {
tableRows[i] = table.Row{strconv.FormatInt(e.ID, 10), e.Name, e.Status}
}
return rows{tableRows}
project, err := m.config.queries.GetProject(context.Background(), m.id)
if err != nil {
return errMsg{err}
}
return project
}
func (m projectModel) fetchTasks() tea.Msg {
tasks, err := m.config.queries.ListTasksByProject(context.Background(), m.id)
id: id,
config: config,
loadingProject: true,
loadingTasks: true,
spinner: config.newSpinner(),
descVw: viewport.New(0, 0),
focus: focusTasks,
id int64
config *config
data *db.Project
err error
loadingProject bool
loadingTasks bool
spinner spinner.Model
descVw viewport.Model
taskList table.Model
focus projectViewFocusElement
"github.com/charmbracelet/lipgloss"
)
type projectViewFocusElement int
const (
focusDesc projectViewFocusElement = iota
focusTasks
"github.com/charmbracelet/bubbles/spinner"
"github.com/charmbracelet/bubbles/table"
"github.com/charmbracelet/bubbles/viewport"
"strconv"
"strings"
package main
import (
"context"
"database/sql"
"fmt"
"strings"
db "github.com/Asfolny/protastim/internal/database"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/huh"
"github.com/charmbracelet/lipgloss"
)
type projectEditModel struct {
config *config
form *huh.Form
width int
create bool
}
func newProjectEdit(config *config, project *db.Project) tea.Model {
m := projectEditModel{config: config, create: project == nil}
confirmDefault := true
var name string
var desc string
var status string
if project != nil {
name = project.Name
desc = project.Description.String
status = project.Status
}
m.form = huh.NewForm(
huh.NewGroup(
huh.NewInput().
Key("name").
Title("Name").
Prompt("> ").
Validate(huh.ValidateNotEmpty()).
Value(&name),
huh.NewText().
Key("description").
Title("Description").
Value(&desc),
huh.NewSelect[string]().
Key("status").
Options(huh.NewOptions("N", "P", "C", "H", "D")...).
Inline(true).
Value(&status),
// TODO make a select between "create and open" and "only create"
huh.NewConfirm().
Validate(func(v bool) error { // Should SHOULD close
if !v {
return fmt.Errorf("Welp, finish up then")
}
return nil
}).
Affirmative("done").
Negative("cancel").
Inline(true).
Value(&confirmDefault),
),
).
WithWidth(45).
WithShowHelp(false).
WithShowErrors(false)
return m
}
func (m projectEditModel) Init() tea.Cmd {
return m.form.Init()
}
func (m projectEditModel) createProjectCmd() func() tea.Msg {
m.config.mu.Lock()
defer m.config.mu.Unlock()
desc := sql.NullString{String: m.form.GetString("description")}
if desc.String != "" {
desc.Valid = true
}
dataset := db.CreateProjectParams{
Name: m.form.GetString("name"),
Description: desc,
Status: m.form.GetString("status"),
}
return func() tea.Msg {
m.config.queries.CreateProject(context.Background(), dataset)
}
}
func (m projectEditModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.width = min(msg.Width, maxWidth) - m.config.styles.Base.GetHorizontalFrameSize()
return m, changeView(newProjectList(m.config))
}
var cmds []tea.Cmd
// Process the form
form, cmd := m.form.Update(msg)
if f, ok := form.(*huh.Form); ok {
m.form = f
cmds = append(cmds, cmd)
}
if m.form.State == huh.StateCompleted {
// TODO handle abort
// TODO handle updating an existing
cmds = append(cmds, m.createProjectCmd())
}
return m, tea.Batch(cmds...)
}
func (m projectEditModel) View() string {
var status string
s := m.config.styles
v := strings.TrimSuffix(m.form.View(), "\n\n")
form := m.config.lg.NewStyle().Margin(1, 0).Render(v)
errors := m.form.Errors()
header := m.appBoundaryView("Charm Employment Application")
if len(errors) > 0 {
header = m.appErrorBoundaryView(m.errorView())
}
body := lipgloss.JoinHorizontal(lipgloss.Top, form, status)
footer := m.appBoundaryView(m.form.Help().ShortHelpView(m.form.KeyBinds()))
if len(errors) > 0 {
footer = m.appErrorBoundaryView("")
}
return s.Base.Render(header + "\n" + body + "\n\n" + footer)
}
func (m projectEditModel) errorView() string {
var s string
for _, err := range m.form.Errors() {
s += err.Error()
}
return s
}
func (m projectEditModel) appBoundaryView(text string) string {
return lipgloss.PlaceHorizontal(
m.width,
lipgloss.Left,
m.config.styles.HeaderText.Render(text),
lipgloss.WithWhitespaceChars("/"),
lipgloss.WithWhitespaceForeground(indigo),
)
}
func (m projectEditModel) appErrorBoundaryView(text string) string {
return lipgloss.PlaceHorizontal(
m.width,
lipgloss.Left,
m.config.styles.ErrorHeaderText.Render(text),
lipgloss.WithWhitespaceChars("/"),
lipgloss.WithWhitespaceForeground(red),
)
}
case editingDone:
return editingDone(true)
Title("Status").
package main
import (
"context"
"fmt"
"strconv"
"github.com/charmbracelet/bubbles/spinner"
"github.com/charmbracelet/bubbles/table"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
type projectListModel struct {
config *config
table table.Model
err error
loading bool
spinner spinner.Model
}
func newProjectList(config *config) projectListModel {
m := projectListModel{
config: config,
loading: true,
spinner: config.newSpinner(),
}
t := table.New(
table.WithColumns([]table.Column{
{Title: "Status", Width: 8},
}),
table.WithFocused(true),
table.WithHeight(8),
)
ts := table.DefaultStyles()
ts.Header = ts.Header.
BorderStyle(lipgloss.NormalBorder()).
BorderForeground(lipgloss.Color("240")).
BorderBottom(true).
Bold(false)
ts.Selected = ts.Selected.
Foreground(lipgloss.Color("229")).
Background(lipgloss.Color("57")).
Bold(false)
t.SetStyles(ts)
m.table = t
return m
}
func (m projectListModel) fetchProjects() tea.Msg {
m.config.mu.RLock()
defer m.config.mu.RUnlock()
row, err := m.config.queries.ListProjects(context.Background())
if err != nil {
return errMsg{err}
}
tableRows := make([]table.Row, len(row), len(row))
for i, e := range row {
}
return rows{tableRows}
}
func (m projectListModel) Init() tea.Cmd {
return tea.Batch(m.fetchProjects, m.spinner.Tick)
}
func (m projectListModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmd tea.Cmd
switch msg := msg.(type) {
case rows:
m.table.SetRows(msg.data)
m.loading = false
return m, nil
case errMsg:
m.err = msg
m.loading = false
return m, tea.Quit
case tea.KeyMsg:
switch msg.String() {
case "ctrl+n":
return m, changeView(newProjectEdit(m.config, nil))
case "enter":
id, _ := strconv.ParseInt(m.table.SelectedRow()[0], 10, 64)
return m, changeView(newProject(m.config, id))
}
}
m.table, cmd = m.table.Update(msg)
return m, cmd
}
func (m projectListModel) View() string {
if m.loading {
return fmt.Sprintf("\n%s Loading\n\n", m.spinner.View())
}
if m.err != nil {
return fmt.Sprintf("\nFailed fetching project: %v\n\n", m.err)
}
}
lg := m.config.lg
return lg.NewStyle().PaddingTop(1).Render(m.table.View())
// TODO this specific handling should be done within Update instead
cols := m.table.Columns()
for i, col := range cols {
if col.Title == "Name" {
col.Width = m.config.getInnerWidth() - 4 - 8 - 2
cols[i] = col
}
}
m.table.SetColumns(cols)
m.table.SetHeight(m.config.getInnerHeight())
m.table.SetWidth(m.config.size.Width)
tableRows[i] = table.Row{strconv.FormatInt(e.ID, 10), e.Status, e.Name}
{Title: "Name"},
{Title: "ID", Width: 2},
package main
import (
"context"
"fmt"
"strconv"
"github.com/charmbracelet/bubbles/spinner"
"github.com/charmbracelet/bubbles/table"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
type taskListModel struct {
config *config
table table.Model
err error
loading bool
spinner spinner.Model
}
func newTaskList(config *config) taskListModel {
m := taskListModel{
config: config,
loading: true,
spinner: config.newSpinner(),
}
t := table.New(
table.WithColumns([]table.Column{
{Title: "ID", Width: 4},
{Title: "Name", Width: 10},
{Title: "Status", Width: 8},
}),
table.WithFocused(true),
table.WithHeight(8),
)
ts := table.DefaultStyles()
ts.Header = ts.Header.
BorderStyle(lipgloss.NormalBorder()).
BorderForeground(lipgloss.Color("240")).
BorderBottom(true).
Bold(false)
ts.Selected = ts.Selected.
Foreground(lipgloss.Color("229")).
Background(lipgloss.Color("57")).
Bold(false)
t.SetStyles(ts)
m.table = t
return m
}
func (m taskListModel) fetchTasks() tea.Msg {
m.config.mu.RLock()
defer m.config.mu.RUnlock()
row, err := m.config.queries.ListTasks(context.Background())
if err != nil {
return errMsg{err}
}
tableRows := make([]table.Row, len(row), len(row))
for i, e := range row {
tableRows[i] = table.Row{strconv.FormatInt(e.ID, 10), e.Name, e.Status}
}
return rows{tableRows}
}
func (m taskListModel) Init() tea.Cmd {
return tea.Batch(m.fetchTasks, m.spinner.Tick)
}
func (m taskListModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmd tea.Cmd
switch msg := msg.(type) {
case rows:
m.table.SetRows(msg.data)
m.loading = false
return m, nil
case errMsg:
m.err = msg
m.loading = false
return m, tea.Quit
case tea.KeyMsg:
switch msg.String() {
case "ctrl+n":
case "enter":
id, _ := strconv.ParseInt(m.table.SelectedRow()[0], 10, 64)
return m, changeView(newTask(m.config, id))
}
}
m.table, cmd = m.table.Update(msg)
return m, cmd
}
func (m taskListModel) View() string {
if m.loading {
return fmt.Sprintf("\n%s Loading\n\n", m.spinner.View())
}
if m.err != nil {
return fmt.Sprintf("\nFailed fetching project: %v\n\n", m.err)
}
return m.config.styles.BaseTable.Render(m.table.View()) + "\n"
}
return m, changeView(newTaskEdit(m.config, nil, ""))
package main
import (
"context"
"database/sql"
"fmt"
"strings"
db "github.com/Asfolny/protastim/internal/database"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/huh"
"github.com/charmbracelet/lipgloss"
)
type taskEditModel struct {
config *config
form *huh.Form
width int
create bool
}
confirmDefault := true
var name string
var desc string
var status string
if task != nil {
name = task.Name
desc = task.Description.String
status = task.Status
}
m.form = huh.NewForm(
huh.NewGroup(
huh.NewInput().
Key("name").
Title("Name").
Prompt("> ").
Validate(huh.ValidateNotEmpty()).
Value(&name),
huh.NewText().
Key("description").
Title("Description").
Value(&desc),
huh.NewSelect[string]().
Key("status").
Options(huh.NewOptions("N", "P", "C", "H", "D")...).
Title("Status ").
Inline(true).
Value(&status),
huh.NewConfirm().
Validate(func(v bool) error { // Should SHOULD close
if !v {
return fmt.Errorf("Welp, finish up then")
}
return nil
}).
Affirmative("done").
Negative("cancel").
Inline(true).
Value(&confirmDefault),
),
).
WithWidth(45).
WithShowHelp(false).
WithShowErrors(false)
return m
}
func (m taskEditModel) Init() tea.Cmd {
}
func (m taskEditModel) createTaskCmd() func() tea.Msg {
m.config.mu.Lock()
defer m.config.mu.Unlock()
desc := sql.NullString{String: m.form.GetString("description")}
if desc.String != "" {
desc.Valid = true
}
dataset := db.CreateTaskParams{
Name: m.form.GetString("name"),
Description: desc,
Status: m.form.GetString("status"),
}
return func() tea.Msg {
m.config.queries.CreateTask(context.Background(), dataset)
return editingDone(true)
}
}
func (m taskEditModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.width = min(msg.Width, maxWidth) - m.config.styles.Base.GetHorizontalFrameSize()
case editingDone:
}
var cmds []tea.Cmd
// Process the form
form, cmd := m.form.Update(msg)
if f, ok := form.(*huh.Form); ok {
m.form = f
cmds = append(cmds, cmd)
}
if m.form.State == huh.StateCompleted {
// TODO handle abort
// TODO handle updating an existing
cmds = append(cmds, m.createTaskCmd())
}
return m, tea.Batch(cmds...)
}
func (m taskEditModel) View() string {
var status string
s := m.config.styles
v := strings.TrimSuffix(m.form.View(), "\n\n")
form := m.config.lg.NewStyle().Margin(1, 0).Render(v)
errors := m.form.Errors()
if len(errors) > 0 {
header = m.appErrorBoundaryView(m.errorView())
}
body := lipgloss.JoinHorizontal(lipgloss.Top, form, status)
footer := m.appBoundaryView(m.form.Help().ShortHelpView(m.form.KeyBinds()))
if len(errors) > 0 {
footer = m.appErrorBoundaryView("")
}
return s.Base.Render(header + "\n" + body + "\n\n" + footer)
}
func (m taskEditModel) errorView() string {
var s string
for _, err := range m.form.Errors() {
s += err.Error()
}
return s
}
func (m taskEditModel) appBoundaryView(text string) string {
return lipgloss.PlaceHorizontal(
m.width,
lipgloss.Left,
m.config.styles.HeaderText.Render(text),
lipgloss.WithWhitespaceChars("/"),
lipgloss.WithWhitespaceForeground(indigo),
)
}
func (m taskEditModel) appErrorBoundaryView(text string) string {
return lipgloss.PlaceHorizontal(
m.width,
lipgloss.Left,
m.config.styles.ErrorHeaderText.Render(text),
lipgloss.WithWhitespaceChars("/"),
lipgloss.WithWhitespaceForeground(red),
)
}
var header string
if m.create {
header = m.appBoundaryView("Create Task")
} else {
header = m.appBoundaryView("Update Task")
}
return m, changeView(newProject(m.config, m.getProjectId()))
case []db.Project:
*m.projects = msg
*m.loading = false
return m, nil
ProjectID: m.getProjectId(),
return tea.Batch(m.form.Init(), m.fetchProjects)
func (m taskEditModel) getProjectId() int64 {
for _, e := range *m.projects {
if e.Name == m.form.GetString("project") {
return e.ID
}
}
return 0
}
}
func (m taskEditModel) projectsAsOptions() []string {
l := make([]string, len(*m.projects) + 1)
l[0] = "Choose a project"
for i, e := range *m.projects {
l[i+1] = e.Name
i++
}
return l
}
func (m taskEditModel) fetchProjects() tea.Msg {
row, err := m.config.queries.ListProjects(context.Background())
if err != nil {
return errMsg{err}
}
return row
huh.NewSelect[string]().
Key("project").
Title("Project").
OptionsFunc(func() []huh.Option[string] {
if *m.loading {
empty := make([]string, 2)
empty[0] = "Loading..."
empty[1] = m.project
return huh.NewOptions(empty...)
}
return huh.NewOptions(m.projectsAsOptions()...)
}, m.loading).
Validate(func(val string) error {
if (val == "Choose a project") {
return errors.New("Must choose a project for task")
}
return nil
}).
Value(&m.project),
func newTaskEdit(config *config, task *db.Task, project string) tea.Model {
t := true
var p []db.Project
m := taskEditModel{config: config, create: task == nil, loading: &t, projects: &p, project: project}
loading *bool
projects *[]db.Project
project string
"errors"