diff --git a/main.go b/main.go index ce918e2..0767b48 100644 --- a/main.go +++ b/main.go @@ -2,235 +2,412 @@ package main import ( "bufio" + + "encoding/json" "errors" "flag" "fmt" - "io" "net" - "regexp" - + "strconv" "strings" "time" - "github.com/charmbracelet/bubbles/textarea" - "github.com/charmbracelet/bubbles/viewport" tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/huh" "github.com/charmbracelet/lipgloss" ) -// colorizeLine applies ANSI styling to usernames based on 6-hex-digit id from the server. -func colorizeLine(s string) string { - reChat := regexp.MustCompile(`^(.+?) \(([0-9a-fA-F]{6})\):[ \t]*(.*)$`) - if m := reChat.FindStringSubmatch(s); m != nil { - id := strings.ToLower(m[2]) - nameStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#" + id)).Bold(true) - name := nameStyle.Render(m[1]) - rest := strings.TrimSpace(m[3]) - return fmt.Sprintf("%s: %s", name, rest) - } +// menuItem represents one option in the server-provided menu. +// Expected JSON (one line): [{"id":"latte","name":"Caffè Latte"}, ...] - reJoinLeave := regexp.MustCompile(`^\[(join|leave)\] (.+?) \(([0-9a-fA-F]{6})\)$`) - if m := reJoinLeave.FindStringSubmatch(s); m != nil { - id := strings.ToLower(m[3]) - nameStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#" + id)).Bold(true) - uname := nameStyle.Render(m[2]) - return fmt.Sprintf("[%s] %s", m[1], uname) - } +// order represents the payload we submit back to the server. - reRename := regexp.MustCompile(`^\[rename\] (.+?) \(([0-9a-fA-F]{6})\) -> (.+)$`) - if m := reRename.FindStringSubmatch(s); m != nil { - id := strings.ToLower(m[2]) - nameStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#" + id)).Bold(true) - oldN := nameStyle.Render(m[1]) - newN := nameStyle.Render(m[3]) - return fmt.Sprintf("[rename] %s -> %s", oldN, newN) +// messages used by Bubble Tea +type ( + connectedMsg struct{ conn net.Conn } + menuLoadedMsg struct { + items []menuItem + err error } - - reWelcome := regexp.MustCompile(`^Welcome (.+?) \(([0-9a-fA-F]{6})\)$`) - if m := reWelcome.FindStringSubmatch(s); m != nil { - id := strings.ToLower(m[2]) - nameStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#" + id)).Bold(true) - uname := nameStyle.Render(m[1]) - return fmt.Sprintf("Welcome %s", uname) + orderSubmittedMsg struct { + ack string + err error } + statusMsg string +) - return s +type FormFields struct { + name string + itemID string + quantityStr string + confirm bool } -type netMsg string -type connectedMsg struct{ conn net.Conn } -type disconnectedMsg struct{} -type errorMsg struct{ err error } - +// model holds the TUI state. type model struct { - vp viewport.Model - input textarea.Model - messages []string + // connection + host string + conn net.Conn - conn net.Conn - server string - err error + // UI + title string + status string + loading bool + err error + lastOrder *order + + // form + form *huh.Form + formFields *FormFields + menu []menuItem + name string + itemID string + quantityStr string + confirm bool } -func initialModel(serverAddr string) model { - vp := viewport.New(80, 20) - vp.Style = lipgloss.NewStyle().Border(lipgloss.NormalBorder()).Padding(0, 1).BorderForeground(lipgloss.Color("#bada55")) - - ta := textarea.New() - ta.FocusedStyle.CursorLine = lipgloss.NewStyle() - ta.Placeholder = "Type message and press Enter..." - ta.Focus() - ta.Prompt = "┃ " - ta.CharLimit = 0 - ta.SetHeight(2) - ta.ShowLineNumbers = false - +// initialModel creates a base model. +func initialModel(host string) model { return model{ - vp: vp, - input: ta, - messages: []string{}, - server: serverAddr, + host: host, + title: "Order Console", + formFields: &FormFields{}, } } func (m model) Init() tea.Cmd { - if m.conn != nil { - return tea.Batch(textarea.Blink, readLineCmd(m.conn)) - } - return tea.Batch(textarea.Blink, connectCmd(m.server)) -} - -func connectCmd(addr string) tea.Cmd { - return func() tea.Msg { - conn, err := net.DialTimeout("tcp", addr, 3*time.Second) - if err != nil { - return netMsg(fmt.Sprintf("[error] connect: %v", err)) - } - - return connectedMsg{conn: conn} - } -} - -func readLineCmd(conn net.Conn) tea.Cmd { - return func() tea.Msg { - if conn == nil { - return disconnectedMsg{} - } - - reader := bufio.NewReader(conn) - line, err := reader.ReadString('\n') - if err != nil { - if errors.Is(err, io.EOF) { - return disconnectedMsg{} - } - return netMsg(fmt.Sprintf("[error] read: %v", err)) - } - - return netMsg(strings.TrimRight(line, "\r\n")) - } -} - -func sendCmd(conn net.Conn, text string) tea.Cmd { - return func() tea.Msg { - if conn == nil { - return netMsg("[error] not connected") - } - - _, err := fmt.Fprintln(conn, text) - if err != nil { - return netMsg(fmt.Sprintf("[error] send: %v", err)) - } - return nil - } + // Connect on startup + return connectCmd(m.host) } func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var cmds []tea.Cmd - switch msg := msg.(type) { + // If a form is active, delegate to it first. + if m.form != nil { + var cmd tea.Cmd + 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 { + // Parse and submit order if confirmed. + qty, err := strconv.Atoi(strings.TrimSpace(m.formFields.quantityStr)) + if err != nil || qty <= 0 { + m.err = fmt.Errorf("invalid quantity: %v", m.formFields.quantityStr) + m.form = nil + return m, nil + } + ord := &order{ + Name: strings.TrimSpace(m.formFields.name), + ItemID: m.formFields.itemID, + Quantity: qty, + } + m.lastOrder = ord + m.form = nil + + if m.formFields.confirm { + if m.conn == nil { + m.status = "Not connected. Unable to submit order." + return m, nil + } + m.err = nil + m.loading = true + m.status = "Submitting order..." + return m, submitOrderCmd(m.conn, *ord) + } + m.status = "Order canceled." + return m, cmd + } + + if m.form.State == huh.StateAborted { + m.status = "Order form aborted." + m.form = nil + return m, cmd + } + + return m, tea.Batch(cmds...) + } + + switch msg := msg.(type) { case connectedMsg: m.conn = msg.conn - m.messages = append(m.messages, "[connected]") - m.refreshViewport() - - return m, readLineCmd(m.conn) - - case disconnectedMsg: - m.messages = append(m.messages, "[disconnected]") - if m.conn != nil { - _ = m.conn.Close() - m.conn = nil - } - m.refreshViewport() + m.status = fmt.Sprintf("Connected to %s", m.host) return m, nil - case netMsg: - m.messages = append(m.messages, colorizeLine(string(msg))) - m.refreshViewport() - - if m.conn != nil && !strings.HasPrefix(string(msg), "[error] read") { - cmds = append(cmds, readLineCmd(m.conn)) + case menuLoadedMsg: + m.loading = false + if msg.err != nil { + m.err = msg.err + m.status = "Failed to load menu." + return m, nil } + m.err = nil + m.menu = msg.items + m.status = "Menu loaded." + // Open the form now that we have a menu. + m.form = m.buildForm() + return m, m.form.Init() + + case orderSubmittedMsg: + m.loading = false + if msg.err != nil { + m.err = msg.err + m.status = "Order submission failed." + return m, nil + } + m.err = nil + if msg.ack != "" { + m.status = fmt.Sprintf("Order submitted. Server says: %s", msg.ack) + } else { + m.status = "Order submitted." + } + return m, nil + + case statusMsg: + m.status = string(msg) + return m, nil case tea.KeyMsg: - switch msg.Type { - case tea.KeyCtrlC, tea.KeyEsc: + switch msg.String() { + case "q", "ctrl+c", "esc": if m.conn != nil { _ = m.conn.Close() } return m, tea.Quit - case tea.KeyEnter: - text := m.input.Value() - if text != "" { - cmds = append(cmds, sendCmd(m.conn, text)) - m.input.SetValue("") - m.refreshViewport() + case "r": + // Reconnect + if m.conn != nil { + _ = m.conn.Close() + m.conn = nil } - return m, tea.Batch(cmds...) + m.status = "Reconnecting..." + return m, connectCmd(m.host) + case "n": + // Start a new order + if m.loading || m.form != nil { + return m, nil + } + if m.conn == nil { + m.status = "Not connected. Press 'r' to reconnect." + return m, nil + } + m.err = nil + m.loading = true + m.status = "Loading menu..." + return m, fetchMenuCmd(m.conn) } case tea.WindowSizeMsg: - // Allocate space: viewport above, input below - m.vp.Width = msg.Width - m.vp.Height = msg.Height - 4 - m.input.SetWidth(msg.Width - 2) - m.refreshViewport() + // No dynamic layout needed, handled in View. } - // Let textarea handle remaining keys - var cmd tea.Cmd - m.input, cmd = m.input.Update(msg) - if cmd != nil { - cmds = append(cmds, cmd) - } - - return m, tea.Batch(cmds...) -} - -func (m *model) refreshViewport() { - contentWidth := m.vp.Width - m.vp.Style.GetHorizontalFrameSize() - - var b strings.Builder - for _, line := range m.messages { - wrapped := lipgloss.NewStyle().Width(contentWidth).Render(line) - b.WriteString(wrapped) - if !strings.HasSuffix(wrapped, "\n") { - b.WriteString("\n") - } - } - m.vp.SetContent(b.String()) - m.vp.GotoBottom() + return m, nil } func (m model) View() string { - header := lipgloss.NewStyle().Width(m.vp.Width).Padding(0, 1).Align(lipgloss.Center).Foreground(lipgloss.Color("241")).Bold(true).Render(m.server) - return lipgloss.JoinVertical( - lipgloss.Left, - header, - m.vp.View(), - m.input.View(), + // Basic centered title and instructions + w := lipgloss.NewStyle().Width(80) + title := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("212")).Render(m.title) + host := lipgloss.NewStyle().Faint(true).Render(m.host) + + lines := []string{ + w.Align(lipgloss.Center).Render(title), + w.Align(lipgloss.Center).Render(host), + "", + "Controls:", + "- n: New order", + "- r: Reconnect", + "- q: Quit", + "", + } + + if m.loading { + lines = append(lines, "Status: "+lipgloss.NewStyle().Foreground(lipgloss.Color("178")).Render("Loading...")) + } else if m.status != "" { + lines = append(lines, "Status: "+m.status) + } + + if m.err != nil { + lines = append(lines, lipgloss.NewStyle().Foreground(lipgloss.Color("9")).Render(fmt.Sprintf("Error: %v", m.err))) + } + + if m.lastOrder != nil { + lines = append(lines, "", "Last order:") + lines = append(lines, fmt.Sprintf("- Name: %s", m.lastOrder.Name)) + // Map selected item label for display + var label string + for _, it := range m.menu { + if it.ID == m.lastOrder.ItemID { + label = it.Name + break + } + } + if label != "" { + lines = append(lines, fmt.Sprintf("- Item: %s (%s)", label, m.lastOrder.ItemID)) + } else { + lines = append(lines, fmt.Sprintf("- Item: %s", m.lastOrder.ItemID)) + } + lines = append(lines, fmt.Sprintf("- Quantity: %d", m.lastOrder.Quantity)) + } + + if m.form != nil { + // When the form is active, render only the form (full screen) + return m.form.View() + } + + return lipgloss.JoinVertical(lipgloss.Left, lines...) +} + +// buildForm constructs the order form: Input (name) -> Select (menu) -> Input (qty) -> Confirm. +func (m *model) buildForm() *huh.Form { + // Convert menu to huh options + opts := make([]huh.Option[string], 0, len(m.menu)) + for _, it := range m.menu { + opts = append(opts, huh.NewOption(it.Name, it.ID)) + } + + // Reset bound fields for a fresh form + m.formFields.name = "" + m.formFields.itemID = "" + m.formFields.quantityStr = "" + m.formFields.confirm = false + + f := huh.NewForm( + huh.NewGroup( + huh.NewInput(). + Title("Your name"). + Prompt("> "). + Placeholder("Jane Doe"). + Value(&m.formFields.name). + Validate(func(s string) error { + if strings.TrimSpace(s) == "" { + return errors.New("name is required") + } + return nil + }), + huh.NewSelect[string](). + Title("Menu item"). + Options(opts...). + Value(&m.formFields.itemID). + Validate(func(v string) error { + if v == "" { + return errors.New("please select a menu item") + } + return nil + }), + huh.NewInput(). + Title("Quantity"). + Prompt("> "). + Placeholder("1"). + Value(&m.formFields.quantityStr). + Validate(func(s string) error { + n, err := strconv.Atoi(strings.TrimSpace(s)) + if err != nil || n <= 0 { + return errors.New("enter a positive integer") + } + return nil + }), + huh.NewConfirm(). + Title("Place order?"). + Affirmative("Yes"). + Negative("No"). + Value(&m.formFields.confirm), + ), ) + + return f +} + +// connectCmd connects to the TCP server. +func connectCmd(addr string) tea.Cmd { + return func() tea.Msg { + conn, err := net.DialTimeout("tcp", addr, 3*time.Second) + if err != nil { + return statusMsg(fmt.Sprintf("Connect failed: %v", err)) + } + // Try to read up to two greeting lines with short deadline (optional). + _ = conn.SetReadDeadline(time.Now().Add(500 * time.Millisecond)) + br := bufio.NewReader(conn) + for i := 0; i < 2; i++ { + if _, err := br.ReadString('\n'); err != nil { + break + } + } + _ = conn.SetReadDeadline(time.Time{}) + + return connectedMsg{conn: conn} + } +} + +// fetchMenuCmd asks the server for a menu via the TCP connection. +// Protocol (proposed): +// - client: "MENU\n" +// - server: single line JSON array: [{"id":"x","name":"..."}]\n +func fetchMenuCmd(conn net.Conn) tea.Cmd { + return func() tea.Msg { + if conn == nil { + return menuLoadedMsg{err: errors.New("not connected")} + } + + // Send request + if _, err := fmt.Fprintln(conn, "MENU"); err != nil { + return menuLoadedMsg{err: fmt.Errorf("send MENU: %w", err)} + } + + // Read single JSON line + _ = conn.SetReadDeadline(time.Now().Add(3 * time.Second)) + defer func() { _ = conn.SetReadDeadline(time.Time{}) }() + r := bufio.NewReader(conn) + line, err := r.ReadString('\n') + if err != nil { + return menuLoadedMsg{err: fmt.Errorf("read MENU: %w", err)} + } + line = strings.TrimRight(line, "\r\n") + // If the server sent an error-ish line, surface it. + if strings.HasPrefix(line, "[error]") { + return menuLoadedMsg{err: fmt.Errorf("server: %s", line)} + } + + var items []menuItem + if err := json.Unmarshal([]byte(line), &items); err != nil { + return menuLoadedMsg{err: fmt.Errorf("invalid menu JSON: %w", err)} + } + return menuLoadedMsg{items: items} + } +} + +// submitOrderCmd sends the order over TCP. +// Protocol (proposed): +// - client: "ORDER \n" +// - server: a single line acknowledgement (freeform), e.g. "OK\n" +func submitOrderCmd(conn net.Conn, ord order) tea.Cmd { + return func() tea.Msg { + if conn == nil { + return orderSubmittedMsg{err: errors.New("not connected")} + } + b, err := json.Marshal(ord) + if err != nil { + return orderSubmittedMsg{err: fmt.Errorf("marshal order: %w", err)} + } + + if _, err := fmt.Fprintf(conn, "ORDER %s\n", string(b)); err != nil { + return orderSubmittedMsg{err: fmt.Errorf("send ORDER: %w", err)} + } + + // Read single-line ack + _ = conn.SetReadDeadline(time.Now().Add(3 * time.Second)) + defer func() { _ = conn.SetReadDeadline(time.Time{}) }() + r := bufio.NewReader(conn) + line, err := r.ReadString('\n') + if err != nil { + return orderSubmittedMsg{err: fmt.Errorf("read ORDER ack: %w", err)} + } + return orderSubmittedMsg{ack: strings.TrimRight(line, "\r\n")} + } } func main() { @@ -238,11 +415,11 @@ func main() { host string serverOnly bool ) - flag.StringVar(&host, "host", "localhost:9000", "host:port to connect to or bind the server on") flag.BoolVar(&serverOnly, "server", false, "run only the server") flag.Parse() + // If requested, start the TCP server (chat server as-is). if serverOnly { if err := startTCPServer(host); err != nil { fmt.Println("Server error:", err) @@ -250,34 +427,10 @@ func main() { return } - time.Sleep(200 * time.Millisecond) - - // Pre-connect and read initial welcome/instruction before starting UI - var preConn net.Conn - var preMsgs []string - if conn, err := net.DialTimeout("tcp", host, 3*time.Second); err == nil { - preConn = conn - // Read up to two initial lines with a short deadline - _ = conn.SetReadDeadline(time.Now().Add(1 * time.Second)) - r := bufio.NewReader(conn) - for i := 0; i < 2; i++ { - line, err := r.ReadString('\n') - if err != nil { - break - } - preMsgs = append(preMsgs, strings.TrimRight(line, "\r\n")) - } - _ = conn.SetReadDeadline(time.Time{}) - } - + // Client TUI m := initialModel(host) - if preConn != nil { - m.conn = preConn - m.messages = append(m.messages, preMsgs...) - } - - p := tea.NewProgram(m, tea.WithAltScreen(), tea.WithMouseCellMotion()) + p := tea.NewProgram(m, tea.WithAltScreen()) if _, err := p.Run(); err != nil { - fmt.Println("Error:", err) + fmt.Println("error:", err) } } diff --git a/server.go b/server.go index d582a0a..9e19be2 100644 --- a/server.go +++ b/server.go @@ -2,15 +2,37 @@ package main import ( "bufio" + "encoding/json" "fmt" "log" "net" + "strconv" "strings" "sync" gonanoid "github.com/matoous/go-nanoid/v2" ) +// menuItem is the structure returned to clients for MENU. +type menuItem struct { + ID string `json:"id"` + Name string `json:"name"` +} + +// defaultMenu is a simple, static menu. Replace or make dynamic as needed. +var defaultMenu = []menuItem{ + {ID: "latte", Name: "Caffè Latte"}, + {ID: "cap", Name: "Cappuccino"}, + {ID: "esp", Name: "Espresso"}, +} + +// order is the structure the server expects for ORDER. +type order struct { + Name string `json:"name"` + ItemID string `json:"itemId"` + Quantity int `json:"quantity"` +} + // broadcast represents a line to send to all connections with the ability // to exclude a single connection (e.g., exclude self on join). type broadcast struct { @@ -129,7 +151,75 @@ func handleConn(h *Hub, c net.Conn) { continue } - // Commands + // New protocol commands: + // MENU -> server returns single-line JSON array of menuItem + if strings.EqualFold(line, "MENU") { + b, err := json.Marshal(defaultMenu) + if err != nil { + fmt.Fprintln(c, `[error] failed to encode menu`) + continue + } + fmt.Fprintln(c, string(b)) + continue + } + + // ORDER -> server validates and replies with a single-line ack + if strings.HasPrefix(line, "ORDER") { + raw := strings.TrimSpace(line[len("ORDER"):]) + var ord order + if err := json.Unmarshal([]byte(raw), &ord); err != nil { + fmt.Fprintln(c, "[error] invalid order json") + continue + } + ord.Name = strings.TrimSpace(ord.Name) + log.Printf("ORDER parsed: name=%q itemId=%q qty=%d", ord.Name, ord.ItemID, ord.Quantity) + if ord.Name == "" { + fmt.Fprintln(c, "[error] missing name") + continue + } + // Fallback handling: accept numeric strings or floats for quantity + if ord.Quantity <= 0 { + var generic map[string]any + if err := json.Unmarshal([]byte(raw), &generic); err == nil { + if v, ok := generic["quantity"]; ok { + switch t := v.(type) { + case string: + if n, err := strconv.Atoi(strings.TrimSpace(t)); err == nil { + ord.Quantity = n + } + case float64: + ord.Quantity = int(t) + } + } + } + } + if ord.Quantity <= 0 { + fmt.Fprintln(c, "[error] invalid quantity") + continue + } + var chosen *menuItem + for i := range defaultMenu { + if defaultMenu[i].ID == ord.ItemID { + chosen = &defaultMenu[i] + break + } + } + if chosen == nil { + fmt.Fprintln(c, "[error] unknown item") + continue + } + + // Optional: broadcast to chat listeners for visibility + h.msgCh <- broadcast{ + text: fmt.Sprintf("[order] %s (%s) ordered %d × %s", username, id, ord.Quantity, chosen.Name), + } + + // Acknowledge to the ordering client + fmt.Fprintln(c, "OK") + continue + } + + // Chat commands if line == "/quit" { break // unified leave handling below }