package main import ( "context" "fmt" "github.com/charmbracelet/bubbles/progress" "github.com/charmbracelet/bubbles/textinput" "github.com/charmbracelet/bubbles/viewport" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "github.com/charmbracelet/wish" bm "github.com/charmbracelet/wish/bubbletea" lm "github.com/charmbracelet/wish/logging" "github.com/gliderlabs/ssh" "github.com/muesli/termenv" "io/ioutil" "log" "math" "os" "os/signal" "strconv" "strings" "syscall" "time" "unicode/utf8" ) var ( titleStyle = func() lipgloss.Style { b := lipgloss.RoundedBorder() b.Right = "├" return lipgloss.NewStyle().BorderStyle(b).Padding(0, 1) }() infoStyle = func() lipgloss.Style { b := lipgloss.RoundedBorder() b.Left = "┤" return titleStyle.Copy().BorderStyle(b) }() ) func main() { log.Println("Welcome on Cemantics!") s, err := wish.NewServer( wish.WithAddress(fmt.Sprintf("%s:%d", "0.0.0.0", 2200)), wish.WithAddress(fmt.Sprintf("%s:%d", "[::]", 2200)), wish.WithHostKeyPath(".ssh/term_info_ed25519"), wish.WithMiddleware( lm.Middleware(), bm.MiddlewareWithColorProfile(teaHandler, termenv.TrueColor), ), ) if err != nil { log.Fatalln(err) } done := make(chan os.Signal, 1) signal.Notify(done, os.Interrupt, syscall.SIGINT, syscall.SIGTERM) log.Printf("Starting SSH server on %s:%d", "[::]", 2200) go func() { if err = s.ListenAndServe(); err != nil { log.Fatalln(err) } }() <-done log.Println("Stopping SSH server") ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer func() { cancel() }() if err := s.Shutdown(ctx); err != nil { log.Fatalln(err) } } func teaHandler(s ssh.Session) (tea.Model, []tea.ProgramOption) { _, _, active := s.Pty() if !active { fmt.Println("no active terminal, skipping") return nil, nil } log.Println(s.RemoteAddr()) ti := textinput.New() ti.Placeholder = "Choisissez un mot ..." ti.Focus() ti.CharLimit = 26 ti.Width = 26 content, err := ioutil.ReadFile("cemantix.txt") if err != nil { log.Printf("Error while opening cemantix.txt: %s", err) return nil, nil } var dictionary = map[string]word{} for i, line := range strings.Split(string(content), "\n") { if line == "" || line[0] == '#' { continue } s := strings.SplitN(line, " ", 2) w := s[0] dist, _ := strconv.ParseFloat(s[1], 64) dictionary[w] = word{w, dist, 1000 - i} } m := model{ err: nil, dictionary: dictionary, textInput: ti, username: s.User(), ip: s.RemoteAddr().String(), words: []word{}, progressBar: progress.New(progress.WithScaledGradient("#FF7CCB", "#FDFF8C")), } return m, []tea.ProgramOption{tea.WithAltScreen(), tea.WithMouseCellMotion()} } type errMsg error type word struct { content string distance float64 ranking int } type model struct { ready bool err error dictionary map[string]word textInput textinput.Model username string ip string words []word wordsViewport viewport.Model progressBar progress.Model maxLength int lastWord string } func (m model) Init() tea.Cmd { return textinput.Blink } func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var cmd tea.Cmd var cmds []tea.Cmd switch msg := msg.(type) { case tea.KeyMsg: switch msg.Type { case tea.KeyEnter: return m.InputWord() case tea.KeyCtrlC, tea.KeyEsc: return m, tea.Quit } case tea.WindowSizeMsg: headerHeight := lipgloss.Height(m.headerView()) footerHeight := lipgloss.Height(m.footerView()) verticalMarginHeight := headerHeight + footerHeight if !m.ready { m.wordsViewport = viewport.New(msg.Width, msg.Height-verticalMarginHeight) m.wordsViewport.YPosition = headerHeight m.ready = true } else { m.wordsViewport.Width = msg.Width m.wordsViewport.Height = msg.Height - verticalMarginHeight } lineWidth := lipgloss.Width(fmt.Sprintf( "* %s %s %s %4d ", strings.Repeat(" ", m.maxLength), "-100.00", " ", 42, )) m.progressBar.Width = msg.Width - lineWidth m.wordsViewport.SetContent(m.wordsView()) case errMsg: m.err = msg return m, nil } m.wordsViewport, cmd = m.wordsViewport.Update(msg) cmds = append(cmds, cmd) m.textInput, cmd = m.textInput.Update(msg) cmds = append(cmds, cmd) return m, tea.Batch(cmds...) } func InsertWord(arr []word, w word) []word { if len(arr) == 0 { return append(arr, w) } w2 := arr[len(arr)-1] if w.content == w2.content { // The word is already present return arr } if w.distance < w2.distance { return append(arr, w) } return append(InsertWord(arr[:len(arr)-1], w), w2) } func (m model) InputWord() (model, tea.Cmd) { input := m.textInput.Value() w, found := m.dictionary[input] if !found { m.err = fmt.Errorf("le mot %s n'existe pas", input) m.textInput.SetValue("") m.textInput.CursorStart() return m, nil } m.lastWord = input if utf8.RuneCountInString(input) > m.maxLength { m.maxLength = utf8.RuneCountInString(input) } m.words = InsertWord(m.words, w) lineWidth := lipgloss.Width(fmt.Sprintf( "* %s %s %s %5d ", strings.Repeat(" ", m.maxLength), "-00.00", " ", 1000, )) m.progressBar.Width = m.wordsViewport.Width - lineWidth m.wordsViewport.SetContent(m.wordsView()) m.textInput.SetValue("") m.textInput.CursorStart() m.err = nil return m, nil } func (w word) View(m model) string { var emoji string if w.ranking == 1000 { emoji = "\U0001F973" } else if w.ranking == 999 { emoji = "\U0001F631" } else if w.ranking >= 990 { emoji = "\U0001F525" } else if w.ranking >= 900 { emoji = "\U0001F975" } else if w.ranking >= 1 { emoji = "\U0001F60E" } else { emoji = "\U0001F976" } var progressBar string var ranking string = " " if w.ranking > 0 { progressBar = m.progressBar.ViewAs(float64(w.ranking) / 1000.0) ranking = fmt.Sprintf("%4d", w.ranking) } distStr := fmt.Sprintf("%.02f", w.distance) if math.Abs(w.distance) < 10 { distStr = " " + distStr } if w.distance >= 0 { distStr = " " + distStr } if math.Abs(w.distance) < 100 { distStr = " " + distStr } return fmt.Sprintf( "* %s %s %s %s %s\n", w.content+strings.Repeat(" ", m.maxLength-utf8.RuneCountInString(w.content)), distStr, emoji, ranking, progressBar, ) } func (m model) headerView() string { var err string if m.err != nil { err = fmt.Sprintf("Erreur : %s", m.err) } msg := fmt.Sprintf( "Veuillez choisir un mot :\n\n%s\n\nDernier mot essayé : %s%s\n%s\n\n", m.textInput.View(), m.lastWord, strings.Repeat(" ", 26), err, ) title := titleStyle.Render("Mots précédents") line := strings.Repeat("─", int(math.Max(0, float64(m.wordsViewport.Width-lipgloss.Width(title))))) msg += lipgloss.JoinHorizontal(lipgloss.Center, title, line) return msg } func (m model) wordsView() string { var content string for _, w := range m.words { content += w.View(m) } return content } func (m model) footerView() string { info := infoStyle.Render(fmt.Sprintf("%3.f%%", m.wordsViewport.ScrollPercent()*100)) line := strings.Repeat("─", int(math.Max(0, float64(m.wordsViewport.Width-lipgloss.Width(info))))) credits := "Inspiration : https://cemantix.herokuapp.com/ (@enigmathix)\n" + "Sources : https://gitlab.crans.org/ynerant/cemantix-charm (@ynerant)\n" + "Données : Wikipédia France (+ Word2Vec), Grammalecte" return lipgloss.JoinVertical(lipgloss.Center, lipgloss.JoinHorizontal(lipgloss.Center, line, info), credits) } func (m model) View() string { if !m.ready { return "\n\tInitialisation…" } return fmt.Sprintf("%s\n%s\n%s", m.headerView(), m.wordsViewport.View(), m.footerView()) }