commit 8694cb0d18e907dcadff337aa68e9145e92374cc Author: Benjamin VAUDOUR Date: Sat Mar 2 15:05:33 2024 +0100 Commit initial diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ee4c926 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/test diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..7a3094a --- /dev/null +++ b/LICENSE @@ -0,0 +1,11 @@ +DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE +Version 2, December 2004 + +Copyright (C) 2004 Sam Hocevar + +Everyone is permitted to copy and distribute verbatim or modified copies of this license document, and changing it is allowed as long as the name is changed. + +DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE +TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION + + 0. You just DO WHAT THE FUCK YOU WANT TO. diff --git a/README.md b/README.md new file mode 100644 index 0000000..56322e5 --- /dev/null +++ b/README.md @@ -0,0 +1,78 @@ +# gfishline + +Gfishline est un prompt Linux inspiré de fish. + +Il supporte la coloration syntaxique et l’autocomplétion. + +## Utilisation typique + +Gfishline peut être utilisé de la façon suivante : + +```go +package main + +import ( + "fmt" + "os" + "gitea.zaclys.net/bvaudour/gfishline" +) + +func main() { + prompt := "\033[1;31m> \033[m" + fl := gfishline.New() + + // Les deux lignes ci-dessous permettent d’utiliser la complétion et le formatage par défaut. + // + // Pour une complétion plus élaborée, utilisez fl.SetCompleter(cb) où cb est une fonction de la forme + // `func (word []string, wordIndex []string, isSpaceIndex bool) (choices []string)`. + // + // Pour un formatage plus élaboré, utilisez fl.SetFormater(cb) où cb est une fonction de la forme + // `func (in [string) (out [string])`. + fl.SetCommands("help", "exit", "print") + fl.SetOptions("--help", "-h") + + defer fl.Close() + for { + input := fl.Prompt(prompt) // fl.PromptPassword(prompt) pour masquer ce qui est saisi + if result, ok := input.Ok(); ok { + //@TODO Do some stuff with input + if result == "exit" { + return + } + } else if err, ok := input.Err(); ok { + fmt.Printf("\033[1,31mErreur système : %s\033[m\n", err) + os.Exit(1) + } + } +} +``` + + + +## Raccourcis clavier disponibles + +| Raccourci clavier | Action | +| -------------------------------------- | ------------------------------------------------------------ | +| Ctrl+A, Home | Retour au début de la saisie | +| Ctrl+E, End | Retour à la fin de la saisie | +| Ctrl+B, Left | Déplacement d’un caractère vers la gauche | +| Ctrl+F, Right | Déplacement d’un caractère vers la droite | +| Alt+B, Ctrl+Left | Déplacement d’un mot vers la gauche | +| Alt+F, Ctrl+Right | Déplacement d’un mot vers la droite | +| Ctrl+U | Suppression de tous les caractères du début de la ligne au curseur (curseur non compris) | +| Ctrl+K | Suppression de tous les carctères du curseur (compris) à la fin de la ligne | +| Ctrl+D (si saisie commencée), Del | Suppression du caractère sous le curseur | +| Ctrl+D (si ligne vide) | Termine l’application (EOF) | +| Ctrl+H, Backspace | Suppression du caractère avant le curseur | +| Alt+D, Alt+Del | Suppression du prochain mot | +| Alt+Backspace | Suppression du précédent mot | +| Ctrl+C | Termine l’application (avec erreur) | +| Ctrl+P, Up (si ligne vide au départ) | Remonte à l’historique précédent | +| Ctrl+N, Down (si ligne vide au départ) | Descend dans l’historique | +| Ctrl+R, Up (si ligne non vide) | Recherche dans l’historique (par préfixe de ligne) à partir de la fin | +| Ctrl+S, Down (si ligne non vide) | Recherche dans l’historique (par préfixe de ligne) à partir du début | +| Ctrl+Y | Copie le dernier élément supprimé | +| Alt+Y | Remonte dans l’historique des éléments supprimés et les copie (implique Ctrl+Y précédemment lancé) | +| Ctrl+G, Cancel | Annule les dernières actions temporaires (historique, recherche, copie, historique de copie) | +| Tab | Complétion suivante | +| Shift+Tab | Complétion précédente | diff --git a/completer.go b/completer.go new file mode 100644 index 0000000..eced51e --- /dev/null +++ b/completer.go @@ -0,0 +1,210 @@ +package gfishline + +import ( + "fmt" + "slices" + "strings" + + "gitea.zaclys.com/bvaudour/gob/format" + "gitea.zaclys.com/bvaudour/gob/shell/console" + "gitea.zaclys.com/bvaudour/gob/shell/console/atom" +) + +func coordToIndex(x, y, c, n int) int { + l, r := n/c, n%c + switch { + case r == 0: + return x*l + y + case x < r: + return x*(l+1) + y + case y >= l: + return -1 + default: + return r*(l+1) + (x-r)*l + y + } +} + +func indexToCoord(i, c, n int) (x, y int) { + l, r := n/c, n%c + switch { + case r == 0: + x, y = i/l, i%l + case i < r*(l+1): + x, y = i/(l+1), i%(l+1) + default: + j := i - r*l + x, y = r+j/l, j%l + } + + return +} + +type CompleteFunc func(words []string, wordIndex int, isIndexSpace bool) (choices []string) + +func NoCompletion(words []string, wordIndex int, isIndexSpace bool) (choices []string) { + return +} + +func DefaultCompletion(commands, options []string) CompleteFunc { + return func(words []string, wordIndex int, isIndexSpace bool) (choices []string) { + if isIndexSpace { + if wordIndex <= 0 { + return commands + } + return options + } + + word, sl := words[wordIndex], options + if wordIndex == 0 { + sl = commands + } + + for _, choice := range sl { + if strings.HasPrefix(choice, word) { + choices = append(choices, choice) + } + } + + return + } +} + +type completer struct { + *splitter + wordIndex int + wordPos int + isInit bool + needAdd bool + choices console.Cycler +} + +func newCompleter(buffer string, cursor int, complete CompleteFunc) *completer { + c := completer{ + splitter: newSplitter(buffer, cursor), + isInit: true, + } + + wordIndex, wordCursor, isIndexSpace := c.compute() + if isIndexSpace && wordCursor == 0 && wordIndex > 0 { + isIndexSpace = false + c.cursor-- + } + + choices := complete(c.words, wordIndex, isIndexSpace) + slices.Sort(choices) + if !isIndexSpace { + current := c.words[wordIndex] + if slices.Contains(choices, current) { + choices = append([]string{current}, slices.DeleteFunc(choices, func(e string) bool { return e == current })...) + } + } + choices = slices.Compact(choices) + + c.choices = atom.NewCycler(true, choices...) + c.needAdd, c.wordIndex, c.wordPos = isIndexSpace, wordIndex, wordCursor + + return &c +} + +func (c *completer) update(next string) { + c.setWord(c.wordIndex, next) + + if c.isInit { + c.cursor += 2 + if _, ok := c.space[c.wordIndex+1]; !ok { + c.space[c.wordIndex+1] = " " + } + c.isInit = false + } +} + +func (c *completer) add(next string) { + c.addWord(c.wordIndex, next, c.wordPos) + c.isInit, c.needAdd = false, false +} + +func (c *completer) set() (ok bool) { + var next string + if next, ok = console.FocusedElement(c.choices).Get(); !ok { + return + } + + if c.needAdd { + c.add(next) + } else { + c.update(next) + } + + return +} + +func (c *completer) prev() bool { return c.choices.Prev() && c.set() } + +func (c *completer) next() bool { return c.choices.Next() && c.set() } + +func (c *completer) move(index int) bool { return c.choices.SetCursor(index) && c.set() } + +func (c *completer) formatChoices(currentWord string, terminalWidth, terminalHeight int) string { + var buf strings.Builder + l, cursor := c.choices.Len(), c.choices.Cursor() + if l == 0 { + return "" + } + + var maxLen int + choices := make([]string, l) + for i := 0; i < l; i++ { + word, _ := c.choices.Index(i).Get() + maxLen, choices[i] = max(atom.VisibleWidth(word)+1, maxLen), word + } + + maxCol := min(max(terminalWidth/maxLen, 1), 5) + lines := l / maxCol + if l%maxCol > 0 { + lines++ + } + lineMin, lineMax := 0, lines-1 + var isPartial bool + if isPartial = terminalHeight < lines; isPartial { + _, cursorLine := indexToCoord(cursor, maxCol, l) + if cursorLine < terminalHeight { + lineMax = min(terminalHeight, lines) - 1 + } else { + lineMin, lineMax = cursorLine+1-min(terminalHeight, lines), cursorLine + } + } + + colWith := terminalWidth / maxCol + if colWith > maxLen { + colWith = (colWith + maxLen) >> 1 + } + for y := lineMin; y <= lineMax; y++ { + if y > 0 { + buf.WriteRune('\n') + } + for x := 0; x < maxCol; x++ { + i := coordToIndex(x, y, maxCol, l) + if i >= 0 { + word := choices[i] + realSize, displaySize := len([]rune(word)), atom.VisibleWidth(word) + size := maxLen + realSize - displaySize + word = strings.TrimPrefix(format.Left(word, size), currentWord) + if i == cursor { + buf.WriteString(format.Apply(currentWord, "bg_l_white", "black", "bold", "underline")) + buf.WriteString(format.Apply(word, "bg_l_white", "black")) + } else { + buf.WriteString(format.Apply(currentWord, "bold", "underline")) + buf.WriteString(word) + } + } + } + } + + if isPartial { + position := fmt.Sprintf("lignes %d à %d (%d)", lineMin+1, lineMax+1, lines) + buf.WriteRune('\n') + buf.WriteString(format.Apply(position, "bg_l_cyan", "black")) + } + + return buf.String() +} diff --git a/fishline.go b/fishline.go new file mode 100644 index 0000000..76efeee --- /dev/null +++ b/fishline.go @@ -0,0 +1,462 @@ +package gfishline + +import ( + "os" + "strings" + + "gitea.zaclys.com/bvaudour/gob/format" + "gitea.zaclys.com/bvaudour/gob/option" + "gitea.zaclys.com/bvaudour/gob/shell/console/atom" +) + +type Fishline struct { + st *atom.State + complete option.Option[CompleteFunc] + format option.Option[FormatFunc] + commands []string + options []string + proposal string + currentWord string + completion option.Option[*completer] +} + +func New() *Fishline { + return &Fishline{ + st: atom.NewState(), + } +} + +func (fl *Fishline) SetCompleter(cb CompleteFunc) { + if cb == nil { + fl.complete = option.None[CompleteFunc]() + } else { + fl.complete = option.Some(cb) + } +} + +func (fl *Fishline) SetFormater(cb FormatFunc) { + if cb == nil { + fl.format = option.None[FormatFunc]() + } else { + fl.format = option.Some(cb) + } +} + +func (fl *Fishline) SetCommands(commands ...string) { + fl.commands = commands +} + +func (fl *Fishline) SetOptions(options ...string) { + fl.options = options +} + +func (fl *Fishline) isCompletionMode() bool { + return fl.completion.IsDefined() +} + +func (fl *Fishline) removeCompletion() (ok bool) { + if ok = fl.completion.IsDefined(); ok { + fl.completion = option.None[*completer]() + fl.currentWord = "" + } + + return +} + +func (fl *Fishline) initCompletion() (completion *completer) { + var ok bool + + if completion, ok = fl.completion.Get(); !ok { + buffer, cursor := fl.st.Buffer() + complete, exists := fl.complete.Get() + if !exists { + complete = DefaultCompletion(fl.commands, fl.options) + } + completion = newCompleter(buffer, cursor, complete) + fl.completion = option.Some(completion) + if !completion.needAdd && completion.wordIndex >= 0 && completion.wordIndex < len(completion.words) { + fl.currentWord = completion.words[completion.wordIndex] + } + } + + return completion +} + +func (fl *Fishline) setCompletion(completion *completer) { + fl.st.UnfocusYank() + fl.st.UnfocusHistory() + fl.st.SaveBuffer() + fl.st.RemoveHistorySearch() + + buffer, cursor := completion.buffer() + fl.st.SetBuffer(buffer) + fl.st.SetCursor(cursor) +} + +func (fl *Fishline) completionPrev() (ok bool) { + completion := fl.initCompletion() + + if ok = completion.prev(); ok { + fl.setCompletion(completion) + } + + return +} + +func (fl *Fishline) completionNext() (ok bool) { + completion := fl.initCompletion() + + if ok = completion.next(); ok { + fl.setCompletion(completion) + } + + return +} + +func (fl *Fishline) fixState() bool { + ok1 := fl.st.RemoveSavedBuffer() + ok2 := fl.st.RemoveHistorySearch() + ok3 := fl.st.UnfocusHistory() + ok4 := fl.st.UnfocusYank() + ok5 := fl.removeCompletion() + + return ok1 || ok2 || ok3 || ok4 || ok5 +} + +func (fl *Fishline) cancel() (ok bool) { + if ok = fl.st.Cancel(); ok { + fl.removeCompletion() + } + + return +} + +func (fl *Fishline) yankPrev() (ok bool) { + if ok = fl.st.YankPrev(); ok { + fl.removeCompletion() + } + + return +} + +func (fl *Fishline) yank() (ok bool) { + if ok = fl.st.Yank(); ok { + fl.removeCompletion() + } + + return +} + +func (fl *Fishline) historyPrev() (ok bool) { + if ok = fl.st.HistoryPrev(); ok { + fl.removeCompletion() + } + + return +} + +func (fl *Fishline) historyNext() (ok bool) { + if ok = fl.st.HistoryNext(); ok { + fl.removeCompletion() + } + + return +} + +func (fl *Fishline) searchHistoryPrev() (ok bool) { + if ok = fl.st.SearchMotivePrev(); ok { + fl.removeCompletion() + } + + return +} + +func (fl *Fishline) searchHistoryNext() (ok bool) { + if ok = fl.st.SearchMotiveNext(); ok { + fl.removeCompletion() + } + + return +} + +func (fl *Fishline) execChar(r atom.Char, isPassword bool) (fix, stop bool) { + if stop = r == '\n' || r == '\r'; stop { + sp := newSplitter(fl.st.Buffer()) + if stop = sp.isComplete(); stop { + fix, fl.proposal = true, "" + return + } + } + + if r != atom.Tab && (r == '\n' || atom.IsPrintable(r)) { + fl.st.Insert(r) + fix = true + if !isPassword { + fl.proposal = fl.st.GetProposal() + } + return + } + + var noBeep bool + var changeProposal int + switch r { + case atom.Bs, atom.C_H: // Back + noBeep, fix, changeProposal = fl.st.Back(), true, 1 + case atom.C_W: // Back word + noBeep, fix, changeProposal = fl.st.BackWord(), true, 1 + case atom.C_D: // Del or reset line + l := fl.st.BufferLen() + if l == 0 { + fl.st.Return() + os.Exit(0) + } else { + noBeep, fix, changeProposal = fl.st.Del(), true, 1 + fl.st.Restart() + } + case atom.C_U: // Remove BOL + noBeep, fix, changeProposal = fl.st.RemoveBegin(), true, 1 + case atom.C_K: // Remove EOL + noBeep, fix, changeProposal = fl.st.RemoveEnd(), true, 1 + case atom.C_A: // Move to BOL + noBeep, fix = fl.st.Begin(), true + fl.proposal = fl.st.GetProposal() + case atom.C_E: // Move to EOL + noBeep, fix = fl.st.End(), true + if !noBeep && fl.proposal != "" { + noBeep, changeProposal = true, -1 + fl.st.SetBuffer(fl.proposal) + } + case atom.C_B: // Move left + noBeep, fix = fl.st.Left(), true + case atom.C_F: // Move right + noBeep, fix = fl.st.Right(), true + if !noBeep && fl.proposal != "" { + noBeep, changeProposal = true, -1 + fl.st.SetBuffer(fl.proposal) + } + case atom.C_T: // Transpose + noBeep, fix, changeProposal = fl.st.Transpose(), true, 1 + case atom.C_P: // Previous history + if !isPassword { + noBeep, changeProposal = fl.historyPrev(), -1 + } + case atom.C_N: // Next history + if !isPassword { + noBeep, changeProposal = fl.historyNext(), -1 + } + case atom.C_S: // Search history + if !isPassword { + noBeep, changeProposal = fl.searchHistoryNext(), -1 + } + case atom.C_R: // Reverse search history + if !isPassword { + noBeep, changeProposal = fl.searchHistoryPrev(), -1 + } + case atom.C_G, atom.Esc: // Cancel history + noBeep, fix, changeProposal = fl.cancel(), true, 1 + case atom.C_Y: // Yank + noBeep, changeProposal = fl.yank(), 1 + case atom.C_C: // Emergency stop + fl.st.Return() + os.Exit(1) + case atom.Tab: + if !isPassword { + noBeep, changeProposal = fl.completionNext(), 1 + } + } + + if !noBeep { + fl.st.Beep() + } else { + if changeProposal > 0 { + fl.proposal = fl.st.GetProposal() + } else if changeProposal < 0 { + fl.proposal = "" + } + } + + return +} + +func (fl *Fishline) execSequence(s atom.Sequence, isPassword bool) (fix, stop bool) { + var noBeep bool + var changeProposal int + switch s { + case atom.Up: // Previous history + if !isPassword { + l := fl.st.BufferLen() + changeProposal = -1 + if l == 0 || fl.st.IsHistoryMode() { + noBeep = fl.historyPrev() + } else { + noBeep = fl.searchHistoryPrev() + } + } + case atom.Down: // Next history + if !isPassword { + l := fl.st.BufferLen() + changeProposal = -1 + if l == 0 || fl.st.IsHistoryMode() { + noBeep = fl.historyNext() + } else { + noBeep = fl.searchHistoryNext() + } + } + case atom.Right: // Move right + noBeep, fix = fl.st.Right(), true + if !noBeep && fl.proposal != "" { + noBeep, changeProposal = true, -1 + fl.st.SetBuffer(fl.proposal) + } + case atom.Left: // Move left + noBeep, fix = fl.st.Left(), true + case atom.Ins: // Toggle insert + fl.st.ToggleReplace() + noBeep = true + case atom.Del: // Delete under cursor + noBeep, fix, changeProposal = fl.st.Del(), true, 1 + case atom.Home: // Move BOL + noBeep, fix = fl.st.Begin(), true + case atom.End: // Move EOL + noBeep, fix = fl.st.End(), true + if !noBeep && fl.proposal != "" { + noBeep, changeProposal = true, -1 + fl.st.SetBuffer(fl.proposal) + } + case atom.A_Bs: // Delete begin of word + noBeep, fix, changeProposal = fl.st.BackWord(), true, 1 + case atom.A_D, atom.A_Del: // Delete end of word + noBeep, fix, changeProposal = fl.st.DelWord(), true, 1 + case atom.C_Right, atom.A_F: // Next word + noBeep, fix = fl.st.NextWord(), true + case atom.C_Left, atom.A_B: // Begin of word + noBeep, fix = fl.st.PrevWord(), true + case atom.A_Y: // Previous yank + noBeep, changeProposal = fl.yankPrev(), 1 + case atom.S_Tab: + noBeep, changeProposal = fl.completionPrev(), 1 + } + + if !noBeep { + fl.st.Beep() + } else { + if changeProposal > 0 { + fl.proposal = fl.st.GetProposal() + } else if changeProposal < 0 { + fl.proposal = "" + } + } + + return +} + +func (fl *Fishline) prompt(p string, isPassword bool) (result option.Result[string]) { + if err, ok := fl.st.SetPrompt(p).Get(); ok { + return option.Err[string](err) + } + + fl.st.Start() + defer fl.st.Stop() + + var refresh func() option.Option[error] + if isPassword { + refresh = func() option.Option[error] { return fl.st.Print("", 0) } + } else { + refresh = func() option.Option[error] { + buffer, cursor := fl.st.Buffer() + var output strings.Builder + if fl.st.IsHistorySearchMode() { + savedBuffer, _ := fl.st.SavedBuffer().Get() + if i := strings.Index(buffer, savedBuffer); i >= 0 { + output.WriteString(buffer[:i]) + output.WriteString(format.Apply(savedBuffer, "bg_l_black")) + output.WriteString(buffer[i+len(savedBuffer):]) + } else { + output.WriteString(buffer) + } + } else { + cb, ok := fl.format.Get() + if !ok { + cb = DefaultFormat(fl.commands, fl.options) + } + f := newFormater(buffer, cursor, cb) + output.WriteString(f.join()) + var suffix string + if fl.proposal != buffer && strings.HasPrefix(fl.proposal, buffer) { + suffix = strings.TrimPrefix(fl.proposal, buffer) + output.WriteString(format.Apply(suffix, "l_black")) + } + if c, ok := fl.completion.Get(); ok { + w, h := fl.st.Width(), fl.st.Height()-1 + wb := atom.VisibleWidth(fl.st.GetPrompt() + buffer + suffix) + hb := wb / w + if wb%w > 0 { + hb++ + } + choices := c.formatChoices(fl.currentWord, w, h-hb) + if choices != "" { + output.WriteString("\n") + output.WriteString(c.formatChoices(fl.currentWord, w, h-hb)) + } + } + } + + //return option.None[error]() + return fl.st.Print(output.String(), cursor) + } + } + refresh() + + var fix, stop, isErr bool + for { + n := fl.st.Next() + if err, ok := n.Err(); ok { + result, isErr = option.Err[string](err), true + break + } + + key, _ := n.Ok() + if s, ok := key.Sequence(); ok { + fix, stop = fl.execSequence(s, isPassword) + } else if c, ok := key.Char(); ok { + fix, stop = fl.execChar(c, isPassword) + } + + if fix { + fl.fixState() + } + + err := refresh() + if e, ok := err.Get(); ok { + result, isErr = option.Err[string](e), true + break + } + + if stop { + break + } + } + + if !isErr { + r, _ := fl.st.Buffer() + result = option.Ok(r) + if !isPassword && len(r) > 0 { + fl.st.AppendHistory(r) + } + } + + fl.st.Clear() + fl.fixState() + fl.st.Return() + + return +} + +// Prompt retourne la chaîne saisie. +func (fl *Fishline) Prompt(p string) option.Result[string] { return fl.prompt(p, false) } + +// PromptPassword agit comme Prompt mais n’affiche pas à l’écran ce qui est saisi. +func (fl *Fishline) PromptPassword(p string) option.Result[string] { return fl.prompt(p, true) } + +// Close ferme proprement le Readline. +func (fl *Fishline) Close() option.Option[error] { return fl.st.Close() } diff --git a/formater.go b/formater.go new file mode 100644 index 0000000..5114775 --- /dev/null +++ b/formater.go @@ -0,0 +1,97 @@ +package gfishline + +import ( + "slices" + "strconv" + "strings" + + "gitea.zaclys.com/bvaudour/gob/format" + "gitea.zaclys.com/bvaudour/gob/shell/scanner" +) + +var ( + specialChars = []string{"=", "*", "+", "-", ">", "<", "|", ">>", "<<"} +) + +func isString(arg string) bool { + if len(arg) > 0 && (arg[0] == '\'' || arg[0] == '"') { + sc := scanner.NewScanner(strings.NewReader(arg)) + return sc.Scan() + } + + return false +} + +func isIncompleteString(arg string) bool { + if len(arg) > 0 && (arg[0] == '\'' || arg[0] == '"') { + sc := scanner.NewScanner(strings.NewReader(arg)) + return !sc.Scan() + } + + return false +} + +func isNumber(arg string) bool { + _, err := strconv.ParseInt(arg, 10, 64) + if err != nil { + _, err = strconv.ParseFloat(arg, 64) + } + + return err == nil +} + +type FormatFunc func(in []string) (out []string) + +func NoFormat(in []string) (out []string) { + return in +} + +func DefaultFormat(commands, options []string) FormatFunc { + return func(in []string) (out []string) { + out = make([]string, len(in)) + for i, word := range in { + var formatOptions []string + switch { + case i == 0 && len(commands) > 0: + if slices.Contains(commands, word) { + formatOptions = append(formatOptions, "bold") + } else if isString(word) { + formatOptions = append(formatOptions, "yellow") + } else { + formatOptions = append(formatOptions, "l_red", "normal") + } + case i > 0 && len(options) > 0 && slices.Contains(options, word): + formatOptions = append(formatOptions, "l_green", "normal") + case isString(word): + formatOptions = append(formatOptions, "yellow") + case isIncompleteString(word): + formatOptions = append(formatOptions, "l_red", "normal") + case isNumber(word): + formatOptions = append(formatOptions, "l_yellow", "normal") + case slices.Contains(specialChars, word): + formatOptions = append(formatOptions, "l_blue", "normal") + } + + if len(formatOptions) > 0 { + out[i] = format.Apply(word, formatOptions...) + } else { + out[i] = word + } + } + + return + } +} + +type formater struct { + *splitter +} + +func newFormater(buffer string, cursor int, cb FormatFunc) *formater { + f := formater{ + splitter: newSplitter(buffer, cursor), + } + f.words = cb(f.words) + + return &f +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..0d9fd16 --- /dev/null +++ b/go.mod @@ -0,0 +1,5 @@ +module gitea.zaclys.com/bvaudour/fishline + +go 1.22.0 + +require gitea.zaclys.com/bvaudour/gob v0.0.0-20240302132617-3307dec97ce9 // indirect diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..f30dbe3 --- /dev/null +++ b/go.sum @@ -0,0 +1,2 @@ +gitea.zaclys.com/bvaudour/gob v0.0.0-20240302132617-3307dec97ce9 h1:rWwHju2jTBu/V7ALO9tYiNJOqqanEDEpfAtNUDGrImQ= +gitea.zaclys.com/bvaudour/gob v0.0.0-20240302132617-3307dec97ce9/go.mod h1:Gw6x0KNKoXv6AMtRkaI+iWM2enVzwHikUSskuEzWQz4= diff --git a/splitter.go b/splitter.go new file mode 100644 index 0000000..d43d170 --- /dev/null +++ b/splitter.go @@ -0,0 +1,161 @@ +package gfishline + +import ( + "strings" + + "gitea.zaclys.com/bvaudour/gob/shell/scanner" +) + +type splitter struct { + space map[int]string + words []string + cursor int +} + +func newSplitter(buffer string, cursor int) *splitter { + sp := splitter{ + space: make(map[int]string), + cursor: cursor, + } + + sc := scanner.NewRawScanner(strings.NewReader(buffer)) + for sc.Scan() { + sp.words = append(sp.words, sc.Text()) + } + for i, word := range sp.words { + if strings.HasPrefix(buffer, word) { + buffer = strings.TrimPrefix(buffer, word) + } else { + tmp := strings.TrimLeft(buffer, " ") + sp.space[i] = strings.Repeat(" ", len(buffer)-len(tmp)) + buffer = strings.TrimPrefix(tmp, word) + } + } + + if len(buffer) > 0 { + l := len(sp.words) + tmp := strings.TrimPrefix(buffer, " ") + if len(tmp) < len(buffer) { + sp.space[l] = strings.Repeat(" ", len(buffer)-len(tmp)) + buffer = tmp + l++ + } + if len(buffer) > 0 { + var lastWord, lastSpace []rune + for _, r := range buffer { + if r == ' ' { + lastSpace = append(lastSpace, r) + } else { + if len(lastWord) == 0 { + lastWord, lastSpace = append(lastWord, lastSpace...), lastSpace[:0] + } + lastWord = append(lastWord, r) + } + } + sp.words = append(sp.words, string(lastWord)) + if len(lastSpace) > 0 { + sp.space[l] = string(lastSpace) + } + } + } + + return &sp +} + +func (sp *splitter) isComplete() (ok bool) { + l := len(sp.words) + if ok = l == 0; !ok { + sc := scanner.NewScanner(strings.NewReader(sp.words[l-1])) + ok = sc.Scan() + } + + return +} + +func (sp *splitter) join() string { + var buf strings.Builder + + for i, word := range sp.words { + if space, ok := sp.space[i]; ok { + buf.WriteString(space) + } + buf.WriteString(word) + } + if space, ok := sp.space[len(sp.words)]; ok { + buf.WriteString(space) + } + + return buf.String() +} + +func (sp *splitter) buffer() (buffer string, cursor int) { return sp.join(), sp.cursor } + +func (sp *splitter) setWord(index int, word string) { + delta := len([]rune(word)) - len([]rune(sp.words[index])) + + sp.words[index] = word + sp.cursor += delta +} + +func (sp *splitter) addWord(index int, word string, wordPos int) { + delta, l := len([]rune(word))+2, len(sp.words) + + words := make([]string, l+1) + copy(words[:index], sp.words[:index]) + copy(words[index+1:], sp.words[index:]) + words[index] = word + + space := make(map[int]string) + for i, s := range sp.space { + switch { + case i < index: + space[i] = s + case i > index: + space[i+1] = s + default: + space[i] = s[:wordPos] + space[i+1] = " " + s[wordPos:] + } + } + + if _, ok := sp.space[index+1]; !ok { + sp.space[index+1] = " " + } + + sp.words, sp.space = words, space + sp.cursor += delta +} + +func (sp *splitter) compute() (wordIndex, wordCursor int, isIndexSpace bool) { + if isIndexSpace = len(sp.words) == 0 && len(sp.space) == 0; isIndexSpace { + return + } + + i := 0 + for wi, word := range sp.words { + if space, ok := sp.space[i]; ok { + l := len(space) + i += l + if isIndexSpace = i > sp.cursor; isIndexSpace { + wordIndex, wordCursor = wi, sp.cursor-(i-l) + return + } + } + l := len([]rune(word)) + i += l + if i > sp.cursor { + wordIndex, wordCursor, isIndexSpace = wi, sp.cursor-(i-l), false + return + } + } + + l := len(sp.words) + wordCursor = sp.cursor - i + if _, isIndexSpace = sp.space[l]; isIndexSpace { + wordIndex = l + } else { + wordIndex = l - 1 + } + + return +}