├── main.go ├── internal ├── util │ ├── cli.go │ ├── file.go │ ├── regex.go │ └── context.go └── ui │ ├── style.go │ ├── view.go │ ├── update.go │ └── model.go ├── .gitignore ├── LICENSE ├── go.mod ├── cmd └── root.go ├── README.md └── go.sum /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/samyakbardiya/trex/cmd" 5 | "github.com/samyakbardiya/trex/internal/util" 6 | ) 7 | 8 | func main() { 9 | f := util.InitLogging() 10 | cmd.Execute() 11 | defer f.Close() 12 | } 13 | -------------------------------------------------------------------------------- /internal/util/cli.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "log" 7 | "os" 8 | 9 | tea "github.com/charmbracelet/bubbletea" 10 | ) 11 | 12 | func InitLogging() *os.File { 13 | if len(os.Getenv("DEBUG")) == 0 { 14 | log.SetOutput(io.Discard) 15 | return nil 16 | } 17 | 18 | f, err := tea.LogToFile("debug.log", "") 19 | if err != nil { 20 | fmt.Println("fatal:", err) 21 | os.Exit(1) 22 | } 23 | log.Println("########################################") 24 | return f 25 | } 26 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Binaries 2 | trex 3 | 4 | # Logs 5 | *.log 6 | 7 | #--------------------------------------------------# 8 | # The following was generated with gitignore.nvim: # 9 | #--------------------------------------------------# 10 | # Gitignore for the following technologies: Go 11 | 12 | # If you prefer the allow list template instead of the deny list, see community template: 13 | # https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore 14 | # 15 | # Binaries for programs and plugins 16 | *.exe 17 | *.exe~ 18 | *.dll 19 | *.so 20 | *.dylib 21 | 22 | # Test binary, built with `go test -c` 23 | *.test 24 | 25 | # Output of the go coverage tool, specifically when used with LiteIDE 26 | *.out 27 | 28 | # Dependency directories (remove the comment below to include it) 29 | # vendor/ 30 | 31 | # Go workspace file 32 | go.work 33 | 34 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Samyak Bardiya 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /internal/util/file.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "os" 7 | "path/filepath" 8 | ) 9 | 10 | func GetFilePath(path string) (string, error) { 11 | absPath, err := resolveFilePath(path) 12 | if err != nil { 13 | return "", err 14 | } 15 | 16 | if err := validateFile(absPath); err != nil { 17 | return "", err 18 | } 19 | 20 | log.Println("filepath:", absPath) 21 | return absPath, nil 22 | } 23 | 24 | func resolveFilePath(path string) (string, error) { 25 | absPath, err := filepath.Abs(filepath.Clean(path)) 26 | if err != nil { 27 | return "", fmt.Errorf("failed to resolve path %q: %w", path, err) 28 | } 29 | 30 | if realPath, err := filepath.EvalSymlinks(absPath); err == nil { 31 | absPath = realPath 32 | } 33 | 34 | return absPath, nil 35 | } 36 | 37 | func validateFile(path string) error { 38 | const maxFileSize = 10 * 1024 * 1024 // 10MB limit 39 | 40 | fileInfo, err := os.Stat(path) 41 | if err != nil { 42 | if os.IsNotExist(err) { 43 | return fmt.Errorf("file does not exist: %s", path) 44 | } 45 | return fmt.Errorf("failed to check file %q: %w", path, err) 46 | } 47 | 48 | if fileInfo.Size() > maxFileSize { 49 | return fmt.Errorf("file size exceeds limit of %d bytes", maxFileSize) 50 | } 51 | 52 | if fileInfo.IsDir() { 53 | return fmt.Errorf("path is a directory, not a file: %s", path) 54 | } 55 | 56 | return nil 57 | } 58 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/samyakbardiya/trex 2 | 3 | go 1.24.0 4 | 5 | require ( 6 | github.com/charmbracelet/bubbles v0.20.0 7 | github.com/charmbracelet/bubbletea v1.3.4 8 | github.com/charmbracelet/lipgloss v1.0.0 9 | github.com/spf13/cobra v1.9.1 10 | golang.design/x/clipboard v0.7.0 11 | ) 12 | 13 | require ( 14 | github.com/atotto/clipboard v0.1.4 // indirect 15 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect 16 | github.com/charmbracelet/x/ansi v0.8.0 // indirect 17 | github.com/charmbracelet/x/term v0.2.1 // indirect 18 | github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect 19 | github.com/inconshreveable/mousetrap v1.1.0 // indirect 20 | github.com/lucasb-eyer/go-colorful v1.2.0 // indirect 21 | github.com/mattn/go-isatty v0.0.20 // indirect 22 | github.com/mattn/go-localereader v0.0.1 // indirect 23 | github.com/mattn/go-runewidth v0.0.16 // indirect 24 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect 25 | github.com/muesli/cancelreader v0.2.2 // indirect 26 | github.com/muesli/termenv v0.16.0 // indirect 27 | github.com/rivo/uniseg v0.4.7 // indirect 28 | github.com/sahilm/fuzzy v0.1.1 // indirect 29 | github.com/spf13/pflag v1.0.6 // indirect 30 | golang.org/x/exp/shiny v0.0.0-20250218142911-aa4b98e5adaa // indirect 31 | golang.org/x/image v0.24.0 // indirect 32 | golang.org/x/mobile v0.0.0-20250218173827-cd096645fcd3 // indirect 33 | golang.org/x/sync v0.11.0 // indirect 34 | golang.org/x/sys v0.30.0 // indirect 35 | golang.org/x/text v0.22.0 // indirect 36 | ) 37 | -------------------------------------------------------------------------------- /internal/util/regex.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "regexp" 7 | "strings" 8 | ) 9 | 10 | type MatchResult struct { 11 | InputText string // input text to be matched 12 | Highlighted string // highlighted match result 13 | Pattern string // regex pattern used for matching 14 | Matches [][]int // positions of the matches found 15 | } 16 | 17 | func (mr *MatchResult) FindMatches() error { 18 | if len(mr.InputText) == 0 { 19 | return fmt.Errorf("empty text") 20 | } 21 | 22 | // TODO: implment customizable flags 23 | re, err := regexp.Compile("(?m)" + mr.Pattern) 24 | if err != nil { 25 | return fmt.Errorf("invalid regular expression: %q: %w", mr.Pattern, err) 26 | } 27 | 28 | mr.Matches = re.FindAllIndex([]byte(mr.InputText), -1) 29 | 30 | log.Printf("MATCHES:\n\texpr: %q\n\tmatches: %v", mr.Pattern, mr.Matches) 31 | return nil 32 | } 33 | 34 | func (mr *MatchResult) IsValidMatch(index int) bool { 35 | if index < 0 || index >= len(mr.Matches) { 36 | return false 37 | } 38 | 39 | match := mr.Matches[index] 40 | return len(match) == 2 && 41 | match[0] >= 0 && 42 | match[1] > match[0] && 43 | match[1] <= len(mr.InputText) 44 | } 45 | 46 | func (mr *MatchResult) HighlightMatches(styleFunc func(...string) string) { 47 | if len(mr.InputText) == 0 || len(mr.Matches) == 0 { 48 | mr.Highlighted = mr.InputText 49 | return 50 | } 51 | 52 | var sb strings.Builder 53 | lastIndex := 0 54 | 55 | for i, match := range mr.Matches { 56 | if !mr.IsValidMatch(i) { 57 | continue 58 | } 59 | 60 | if match[0] > lastIndex { 61 | sb.WriteString(mr.InputText[lastIndex:match[0]]) 62 | } 63 | 64 | matchedText := mr.InputText[match[0]:match[1]] 65 | sb.WriteString(styleFunc(matchedText)) 66 | 67 | lastIndex = match[1] 68 | } 69 | 70 | if lastIndex < len(mr.InputText) { 71 | sb.WriteString(mr.InputText[lastIndex:]) 72 | } 73 | 74 | mr.Highlighted = sb.String() 75 | log.Printf("highlighted:\n%q\n%s", mr.Highlighted, mr.Highlighted) 76 | } 77 | -------------------------------------------------------------------------------- /cmd/root.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "log" 7 | "os" 8 | "strings" 9 | 10 | tea "github.com/charmbracelet/bubbletea" 11 | "github.com/samyakbardiya/trex/internal/ui" 12 | "github.com/samyakbardiya/trex/internal/util" 13 | "github.com/spf13/cobra" 14 | ) 15 | 16 | const version = "v0.0.1" 17 | 18 | var rootCmd = &cobra.Command{ 19 | Use: "trex [file]", 20 | Short: "A TUI tool to work with RegEx", 21 | Long: "A TUI tool to work with RegEx", 22 | Example: util.CliExample, 23 | Args: cobra.MaximumNArgs(1), 24 | Version: version, 25 | PreRunE: preRun, 26 | RunE: run, 27 | SilenceUsage: true, 28 | } 29 | 30 | func Execute() { 31 | if err := rootCmd.Execute(); err != nil { 32 | os.Exit(1) 33 | } 34 | } 35 | 36 | func preRun(cmd *cobra.Command, args []string) error { 37 | data, err := loadInputData(args) 38 | if err != nil { 39 | return fmt.Errorf("failed to load input data: %w", err) 40 | } 41 | 42 | log.Printf("content: %q", data) 43 | ctx := context.WithValue(cmd.Context(), util.KeyFileData, data) 44 | cmd.SetContext(ctx) 45 | return nil 46 | } 47 | 48 | func run(cmd *cobra.Command, args []string) error { 49 | data, ok := cmd.Context().Value(util.KeyFileData).([]byte) 50 | if !ok { 51 | return fmt.Errorf("unable to read content") 52 | } 53 | 54 | p := tea.NewProgram( 55 | ui.New(string(data)), 56 | tea.WithAltScreen(), 57 | tea.WithMouseCellMotion(), 58 | ) 59 | if _, err := p.Run(); err != nil { 60 | return fmt.Errorf("error while running program: %w", err) 61 | } 62 | 63 | return nil 64 | } 65 | 66 | func loadInputData(args []string) ([]byte, error) { 67 | var text string 68 | 69 | if len(args) == 0 { 70 | log.Println("Using default text") 71 | text = util.DefaultText 72 | } else { 73 | log.Println("Reading from file") 74 | filePath, err := util.GetFilePath(args[0]) 75 | if err != nil { 76 | return nil, err 77 | } 78 | 79 | data, err := os.ReadFile(filePath) 80 | if err != nil { 81 | return nil, fmt.Errorf("failed to read file: %w", err) 82 | } 83 | 84 | text = string(data) 85 | } 86 | 87 | text = strings.TrimSpace(text) 88 | text = strings.ReplaceAll(text, "\r\n", "\n") 89 | 90 | return []byte(text), nil 91 | } 92 | -------------------------------------------------------------------------------- /internal/util/context.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | type ContextKey string 4 | 5 | const ( 6 | // context key for the file data. 7 | KeyFileData ContextKey = "filedata" 8 | 9 | // example for cli usage 10 | CliExample = ` trex # Run with default sample text 11 | trex myfile.txt # Process specific file 12 | trex /path/to/file.md # Process file with absolute path` 13 | 14 | // default text content 15 | DefaultText = `Lorem ipsum odor amet, consectetuer adipiscing elit. Semper tristique curabitur netus facilisi commodo pellentesque. Dignissim habitant metus massa fermentum aliquam leo praesent vestibulum. Duis et enim ex non conubia leo. Aptent gravida hendrerit odio ultricies cras dolor vulputate placerat? Posuere lacus interdum; ac curae nibh sit vestibulum. Fusce elementum nec sed purus sollicitudin, class ullamcorper purus!` 16 | 17 | // // HACK: Not working for longer default text 18 | // DefaultText = ` 19 | // 20 | // Lorem ipsum odor amet, consectetuer adipiscing elit. Semper tristique curabitur netus facilisi commodo pellentesque. Dignissim habitant metus massa fermentum aliquam leo praesent vestibulum. Duis et enim ex non conubia leo. Aptent gravida hendrerit odio ultricies cras dolor vulputate placerat? Posuere lacus interdum; ac curae nibh sit vestibulum. Fusce elementum nec sed purus sollicitudin, class ullamcorper purus! 21 | // 22 | // Etiam finibus purus dolor semper eu posuere mi lectus. Conubia lacus augue dolor porttitor leo quisque blandit. Potenti risus maecenas a potenti class velit fringilla mauris. Montes in faucibus gravida luctus aptent iaculis. Ex condimentum curabitur tempor ad at. Elementum fringilla fusce mauris primis porta. Ut adipiscing dis cursus id nec hendrerit efficitur. Sit montes taciti neque; cursus ante venenatis. Sagittis risus ex eget habitant non. Volutpat varius orci aptent; facilisis blandit rhoncus est. 23 | // 24 | // Congue fringilla parturient donec aptent mattis nam. Ac conubia eu efficitur ac aenean non fusce. Penatibus rhoncus cras diam justo primis lobortis. Ad quam ullamcorper vestibulum vulputate senectus. Placerat tristique sollicitudin nisl varius penatibus consequat vivamus. Primis habitant nam libero cubilia, tortor nulla malesuada. Erat elit conubia fusce consectetur, litora blandit dui suscipit. Ultricies habitant magna magnis habitasse et dapibus malesuada. Vestibulum gravida consequat risus cursus, sociosqu dis. Mus primis augue bibendum penatibus ac euismod. 25 | // 26 | // Massa hac conubia cursus elementum tempor laoreet posuere dictum. Molestie non sem pretium vitae orci nec. Inceptos ad imperdiet dis curae pellentesque conubia eget. Non purus etiam senectus consequat vehicula ullamcorper habitasse netus condimentum. Consectetur volutpat vivamus est; integer fames tincidunt mus. Orci tristique ornare odio, potenti sociosqu class ligula consequat. Dui feugiat adipiscing ultrices imperdiet turpis pellentesque magna risus. Cubilia montes litora nibh praesent habitasse. Sollicitudin cras nullam interdum lorem vivamus ex sociosqu primis. 27 | // 28 | // Venenatis pellentesque ultricies hac condimentum; vel enim. Ligula mauris tristique auctor nam elit fames et? Diam fringilla habitant orci nisi convallis nibh velit. Malesuada arcu taciti nisi bibendum ultrices lacus porttitor. Nibh sem praesent rhoncus ultricies tempor commodo orci. Tristique ante lacinia ipsum orci eu nisi. Risus maximus cursus tincidunt cras lorem orci velit dolor hac. Turpis a taciti natoque; sit ut arcu suspendisse lacinia. Ornare convallis mus volutpat etiam pulvinar euismod. 29 | // ` 30 | ) 31 | -------------------------------------------------------------------------------- /internal/ui/style.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "fmt" 5 | "strconv" 6 | "strings" 7 | 8 | "github.com/charmbracelet/lipgloss" 9 | ) 10 | 11 | const ( 12 | colorsPerRow = 8 13 | maxColors = 16 14 | minWidth = 80 15 | minHeight = 24 16 | leftWidthRatio = 0.70 17 | rightWidthRatio = 0.30 18 | minInputHeight = 4 19 | minHelpHeight = 2 20 | borderWidthDiff = 2 // self border 21 | widthDiff = 20 // offset caused by the borders 22 | widthCheatsheet = 30 // width of the cheatsheet 23 | ) 24 | 25 | var ( 26 | cBlack = lipgloss.Color("00") 27 | cRed = lipgloss.Color("01") 28 | cGreen = lipgloss.Color("02") 29 | cYellow = lipgloss.Color("03") 30 | cBlue = lipgloss.Color("04") 31 | cMagenta = lipgloss.Color("05") 32 | cCyan = lipgloss.Color("06") 33 | cLightGray = lipgloss.Color("07") 34 | cGray = lipgloss.Color("08") 35 | cLightRed = lipgloss.Color("09") 36 | cLightGreen = lipgloss.Color("10") 37 | cLightYellow = lipgloss.Color("11") 38 | cLightBlue = lipgloss.Color("12") 39 | cLightMagenta = lipgloss.Color("13") 40 | cLightCyan = lipgloss.Color("14") 41 | cWhite = lipgloss.Color("15") 42 | ) 43 | 44 | // text-style 45 | var ( 46 | tsHelp = lipgloss.NewStyle().Foreground(cGray) 47 | tsHighlight = lipgloss.NewStyle().Foreground(cBlack).Background(cGreen).Bold(true) 48 | tsNormal = lipgloss.NewStyle() 49 | tsCheatsheetDescription = lipgloss.NewStyle() 50 | tsCheatcode = lipgloss.NewStyle().Foreground(cYellow) 51 | tsTemplate = lipgloss.NewStyle().Foreground(cRed) 52 | tsListDefault = lipgloss.NewStyle() 53 | tsListSelected = lipgloss.NewStyle().Reverse(true).Bold(true) 54 | ) 55 | 56 | // border-style 57 | var ( 58 | bsError = lipgloss.NewStyle().BorderStyle(lipgloss.RoundedBorder()).BorderForeground(lipgloss.Color(cLightRed)) 59 | bsFocus = lipgloss.NewStyle().BorderStyle(lipgloss.RoundedBorder()).BorderForeground(lipgloss.Color(cLightBlue)) 60 | bsUnfocus = lipgloss.NewStyle().BorderStyle(lipgloss.RoundedBorder()) 61 | bsSuccess = lipgloss.NewStyle().BorderStyle(lipgloss.RoundedBorder()).BorderForeground(lipgloss.Color(cLightGreen)) 62 | ) 63 | 64 | func PreviewStyles() string { 65 | var b strings.Builder 66 | fmt.Fprintf(&b, "\n%s\n%s\n%s", 67 | bsError.Render("\tERROR\t"), 68 | bsFocus.Render("\tFOCUS\t"), 69 | bsUnfocus.Render("\tUNFOCUS\t"), 70 | ) 71 | fmt.Fprintf(&b, "\n\n%s%s%s", 72 | tsHelp.Render("\tHELP\t"), 73 | tsHighlight.Render("\tHIGHLIGHT\t"), 74 | tsNormal.Render("\tNORMAL\t"), 75 | ) 76 | return b.String() 77 | } 78 | 79 | func PreviewColors() string { 80 | var b strings.Builder 81 | b.WriteString("\n\n") 82 | renderColorPreview(&b, renderForegroundColor) 83 | b.WriteString("\n\n") 84 | renderColorPreview(&b, renderBackgroundColor) 85 | return b.String() 86 | } 87 | 88 | // renderColorPreview handles the common logic for rendering color previews 89 | func renderColorPreview(b *strings.Builder, renderFunc func(int) string) { 90 | for i := 0; i < maxColors; i++ { 91 | b.WriteString(renderFunc(i)) 92 | if (i+1)%colorsPerRow == 0 { 93 | b.WriteString("\n") 94 | } 95 | } 96 | } 97 | 98 | func renderForegroundColor(i int) string { 99 | return lipgloss.NewStyle(). 100 | Foreground(lipgloss.Color(strconv.Itoa(i))). 101 | Render(fmt.Sprintf(" %3d ", i)) 102 | } 103 | 104 | func renderBackgroundColor(i int) string { 105 | return lipgloss.NewStyle(). 106 | Background(lipgloss.Color(strconv.Itoa(i))). 107 | Render(fmt.Sprintf(" %3d ", i)) 108 | } 109 | -------------------------------------------------------------------------------- /internal/ui/view.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | tea "github.com/charmbracelet/bubbletea" 8 | "github.com/charmbracelet/lipgloss" 9 | ) 10 | 11 | const ( 12 | helpText = "\ntab: focus next • q: exit\n" 13 | ) 14 | 15 | func (m model) View() string { 16 | if m.width < minWidth || m.height < minHeight { 17 | return m.renderBox("Window too small!\nPlease resize.", bsError) 18 | } 19 | 20 | switch m.state { 21 | case stateActive: 22 | return lipgloss.JoinVertical( 23 | lipgloss.Center, 24 | lipgloss.JoinHorizontal( 25 | lipgloss.Center, 26 | lipgloss.JoinVertical( 27 | lipgloss.Center, 28 | m.renderInputField(), 29 | m.renderContentView(), 30 | ), 31 | m.renderCheatsheet(), 32 | ), 33 | m.renderHelpText(), 34 | ) 35 | case stateNotification: 36 | return m.renderBox("RegEx copied to clipboard!", bsSuccess) 37 | case stateExiting: 38 | return m.renderBox("Do you really want to quit? [y/N]", bsError) 39 | default: 40 | return "" 41 | } 42 | } 43 | 44 | func (m model) renderInputField() string { 45 | text := tsNormal.Render("TReX => ") + tsHelp.Render("/") + m.input.View() + tsHelp.Render("/gm") 46 | style := bsUnfocus 47 | if m.err != nil { 48 | style = bsError 49 | } else if m.focus == focusInput { 50 | style = bsFocus 51 | } 52 | maxWidth := int(float32(m.width)*leftWidthRatio) - borderWidthDiff 53 | return style.Width(maxWidth).Render(text) 54 | } 55 | 56 | func (m model) renderContentView() string { 57 | if m.focus == focusContent { 58 | return bsFocus.Render(m.viewport.View()) 59 | } 60 | return bsUnfocus.Render(m.viewport.View()) 61 | } 62 | 63 | func (m model) renderCheatsheet() string { 64 | view := m.cheatsheet.View() 65 | if m.focus == focusCheatsheet { 66 | return bsFocus.Render(view) 67 | } 68 | return bsUnfocus.Render(view) 69 | } 70 | 71 | func (m model) renderCentered(str string) string { 72 | return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, str) 73 | } 74 | 75 | func (m model) renderBox(message string, style lipgloss.Style) string { 76 | box := style.Padding(1, 4).Render(message) 77 | if w, _ := lipgloss.Size(box); m.width < w { 78 | return m.renderCentered(lipgloss.NewStyle().Width(m.width).Render(message)) 79 | } 80 | return m.renderCentered(box) 81 | } 82 | 83 | func (m model) renderHelpText() string { 84 | var focusSpecificKeyBindings []keyBinding 85 | switch m.focus { 86 | case focusInput: 87 | focusSpecificKeyBindings = []keyBinding{ 88 | {description: "clear", binding: tea.KeyCtrlW.String()}, 89 | {description: "copy regex", binding: tea.KeyEnter.String()}, 90 | } 91 | case focusContent: 92 | focusSpecificKeyBindings = []keyBinding{ 93 | {description: "scroll up", binding: tea.KeyUp.String()}, 94 | {description: "scroll down", binding: tea.KeyDown.String()}, 95 | } 96 | case focusCheatsheet: 97 | focusSpecificKeyBindings = []keyBinding{ 98 | {description: "up", binding: tea.KeyUp.String()}, 99 | {description: "down", binding: tea.KeyDown.String()}, 100 | {description: "next page", binding: tea.KeyRight.String()}, 101 | {description: "prev page", binding: tea.KeyLeft.String()}, 102 | {description: "goto start", binding: tea.KeyHome.String()}, 103 | {description: "goto end", binding: tea.KeyEnd.String()}, 104 | } 105 | } 106 | 107 | baseKeyBindings := []keyBinding{ 108 | {description: "Focus Next", binding: tea.KeyTab.String()}, 109 | {description: "Quit", binding: tea.KeyEsc.String()}, 110 | {description: "Force Quit", binding: tea.KeyCtrlC.String()}, 111 | } 112 | keyBinding := append(focusSpecificKeyBindings, baseKeyBindings...) 113 | 114 | var keyMap []string 115 | for _, kb := range keyBinding { 116 | keyMap = append(keyMap, fmt.Sprintf("%s: <%s>", kb.description, kb.binding)) 117 | } 118 | return tsHelp.MaxWidth(m.width).Render(strings.Join(keyMap, " | ")) 119 | } 120 | -------------------------------------------------------------------------------- /internal/ui/update.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "time" 5 | 6 | tea "github.com/charmbracelet/bubbletea" 7 | "golang.design/x/clipboard" 8 | ) 9 | 10 | func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 11 | switch msg := msg.(type) { 12 | case tea.KeyMsg: 13 | return m.handleKeyMsg(msg) 14 | case tea.WindowSizeMsg: 15 | return m.handleWindowSizeMsg(msg) 16 | case tea.MouseMsg: 17 | return m.handleMouseMsg(msg) 18 | case tickMsg: 19 | return m.handleTickMsg() 20 | default: 21 | return m, nil 22 | } 23 | } 24 | 25 | func (m model) handleKeyMsg(msg tea.KeyMsg) (tea.Model, tea.Cmd) { 26 | switch msg.Type { 27 | case tea.KeyEsc: 28 | return m.toggleState() 29 | case tea.KeyTab: 30 | return m.getNextFocus() 31 | case tea.KeyCtrlC: 32 | return m, tea.Quit 33 | } 34 | 35 | switch m.state { 36 | case stateExiting: 37 | switch msg.String() { 38 | case "y", "Y": 39 | return m, tea.Quit 40 | default: 41 | return m.toggleState() 42 | } 43 | case stateNotification: 44 | return m, nil // blocks KeyMsg 45 | } 46 | 47 | var cmd tea.Cmd 48 | switch m.focus { 49 | case focusInput: 50 | return m.handleInputUpdate(msg) 51 | case focusContent: 52 | m.viewport, cmd = m.viewport.Update(msg) 53 | return m, cmd 54 | case focusCheatsheet: 55 | return m.handleCheatsheetKeyMsg(msg) 56 | } 57 | 58 | return m, nil 59 | } 60 | 61 | func (m model) handleWindowSizeMsg(msg tea.WindowSizeMsg) (tea.Model, tea.Cmd) { 62 | m.width = msg.Width 63 | m.height = msg.Height 64 | 65 | maxViewportWidth := int(float32(m.width)*leftWidthRatio) - borderWidthDiff 66 | m.viewport.Width = maxViewportWidth 67 | m.viewport.Height = m.height - minHelpHeight - minInputHeight 68 | 69 | maxCheatsheetWidth := int(float32(m.width)*rightWidthRatio) - borderWidthDiff 70 | m.cheatsheet.SetWidth(maxCheatsheetWidth) 71 | m.cheatsheet.SetHeight(m.height - minHelpHeight - 1) 72 | 73 | return m, nil 74 | } 75 | 76 | func (m model) handleMouseMsg(msg tea.MouseMsg) (tea.Model, tea.Cmd) { 77 | var cmd tea.Cmd 78 | var cmds []tea.Cmd 79 | 80 | m.viewport, cmd = m.viewport.Update(msg) 81 | cmds = append(cmds, cmd) 82 | 83 | m.cheatsheet, cmd = m.cheatsheet.Update(msg) 84 | cmds = append(cmds, cmd) 85 | 86 | return m, tea.Batch(cmds...) 87 | } 88 | 89 | func (m model) handleTickMsg() (tea.Model, tea.Cmd) { 90 | switch m.state { 91 | case stateNotification: 92 | m.state = stateActive // resets state 93 | } 94 | return m, nil 95 | } 96 | 97 | func (m *model) handleInputUpdate(msg tea.KeyMsg) (tea.Model, tea.Cmd) { 98 | var cmd tea.Cmd 99 | m.input, cmd = m.input.Update(msg) 100 | 101 | switch msg.Type { 102 | case tea.KeyEnter: 103 | if err := clipboard.Init(); err == nil { 104 | clipboard.Write(clipboard.FmtText, []byte(m.matchRes.Pattern)) 105 | m.state = stateNotification 106 | return m, tea.Batch(cmd, timeout(1*time.Second)) 107 | } 108 | default: 109 | if m.matchRes.Pattern != m.input.Value() { 110 | m.matchRes.Pattern = m.input.Value() 111 | m.updateRegexMatches() 112 | } 113 | } 114 | 115 | return m, cmd 116 | } 117 | 118 | func (m *model) handleCheatsheetKeyMsg(msg tea.KeyMsg) (tea.Model, tea.Cmd) { 119 | var cmd tea.Cmd 120 | m.cheatsheet, cmd = m.cheatsheet.Update(msg) 121 | 122 | switch msg.Type { 123 | case tea.KeyEnter: 124 | i := m.cheatsheet.Index() 125 | items := m.cheatsheet.Items() 126 | if i < 0 || i >= len(items) { 127 | return m, cmd 128 | } 129 | 130 | inValue := m.input.Value() 131 | selected, ok := items[i].(item) 132 | if !ok { 133 | return m, cmd 134 | } 135 | 136 | switch selected.itemType { 137 | case itemCheatcode: 138 | m.matchRes.Pattern = inValue + selected.pattern 139 | m.input.SetValue(m.matchRes.Pattern) 140 | case itemTemplate: 141 | m.matchRes.Pattern = selected.pattern 142 | m.matchRes.InputText = selected.testStr 143 | m.input.SetValue(m.matchRes.Pattern) 144 | m.viewport.SetContent(m.matchRes.InputText) 145 | } 146 | m.updateRegexMatches() 147 | } 148 | 149 | return m, cmd 150 | } 151 | 152 | func (m *model) updateRegexMatches() { 153 | if m.err = m.matchRes.FindMatches(); m.err != nil { 154 | return 155 | } 156 | m.matchRes.HighlightMatches(tsHighlight.Render) 157 | m.viewport.SetContent(m.matchRes.Highlighted) 158 | } 159 | 160 | func (m *model) getNextFocus() (tea.Model, tea.Cmd) { 161 | var nextFocus focus 162 | switch m.focus { 163 | case focusInput: 164 | nextFocus = focusContent 165 | case focusContent: 166 | nextFocus = focusCheatsheet 167 | case focusCheatsheet: 168 | nextFocus = focusInput 169 | default: 170 | nextFocus = focusInput 171 | } 172 | m.focus = nextFocus 173 | m.cheatsheet.SetDelegate(itemDelegate{focus: nextFocus}) 174 | return m, nil 175 | } 176 | 177 | func (m *model) toggleState() (tea.Model, tea.Cmd) { 178 | switch m.state { 179 | case stateActive: 180 | m.state = stateExiting 181 | default: 182 | m.state = stateActive 183 | } 184 | return m, nil 185 | } 186 | 187 | func timeout(duration time.Duration) tea.Cmd { 188 | return func() tea.Msg { 189 | time.Sleep(duration) 190 | return tickMsg{} 191 | } 192 | } 193 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | # TReX :t-rex: 5 | 6 | 7 |

8 | GitHub Release 9 | Go Reference 10 | Go Report Card 11 | License: MIT 12 |

13 | 14 | 15 | **TReX** is a terminal-based tool for writing, visualizing, and testing Regular 16 | Expressions. Designed for efficiency, it provides a keyboard-driven interface 17 | for rapid feedback on your regex experiments—all within your terminal. 18 | 19 | [![asciicast](https://asciinema.org/a/704948.svg)](https://asciinema.org/a/704948) 20 | 21 | 22 | 23 | 24 | 25 | 26 | - [Why TReX?](#why-trex) 27 | * [Okay, but why "TReX"?](#okay-but-why-trex) 28 | - [Features](#features) 29 | - [Installation](#installation) 30 | * [From the source](#from-the-source) 31 | - [Usage](#usage) 32 | - [Roadmap](#roadmap) 33 | - [Contributing](#contributing) 34 | - [Implementation](#implementation) 35 | 36 | 37 | 38 | 39 | 40 | 41 | ## Why TReX? 42 | 43 | [![xkcd comic about Regular Expressions](https://imgs.xkcd.com/comics/regular_expressions.png)](https://xkcd.com/208) 44 | 45 | Sometimes you just want to quickly test out a regex without switching between 46 | multiple browser tabs or online tools. TReX lets you see how your regex 47 | interacts with your text in real time—all within your terminal. 48 | 49 | - **Quick feedback:** Validate and debug regex patterns instantly. 50 | - **Integrated testing:** Load files and experiment with regex combinations. 51 | - **Efficient workflow:** Stay in your terminal and keep your focus on writing code. 52 | 53 | ### Okay, but why "TReX"? 54 | 55 | TReX, is a playful fusion of TUI and RegEx. The `T` comes from **T**UI, 56 | while `ReX` from **R**eg**Ex**, hence **_TReX_** :t-rex:. Roar! 57 | 58 | ## Features 59 | 60 | - **Written in Go:** Fast and portable. 61 | - **External file loading:** Test regex patterns against real-world data. 62 | - **Keyboard-driven interface:** Navigate without the need for a mouse. 63 | - **Mouse support:** For users who prefer it or need it. 64 | 65 | ## Installation 66 | 67 | - **Install from Go Package Reference**: 68 | 69 | ```sh 70 | go install github.com/samyakbardiya/trex@latest 71 | ``` 72 | 73 |
74 | OR from the source 75 | 76 | ### From the source 77 | 78 | - **Clone the repository:** 79 | 80 | ```sh 81 | git clone https://github.com/samyakbardiya/trex.git 82 | cd trex 83 | ``` 84 | 85 | - **Build the application:** 86 | 87 | ```sh 88 | go install 89 | go build 90 | ``` 91 | 92 | - **_Optionally_, you can copy the binary to your `PATH`:** 93 | 94 | ```sh 95 | cp ./trex ~/.local/bin 96 | ``` 97 | 98 | - **Verify the installation:** 99 | 100 | ```sh 101 | ./trex --version 102 | ``` 103 | 104 |
105 | 106 | ## Usage 107 | 108 | - **Start TReX:** 109 | 110 | ```sh 111 | trex 112 | ``` 113 | 114 | - **Load a file into TReX:** 115 | 116 | ```sh 117 | trex file.txt 118 | ``` 119 | 120 | - **Advanced usage:** Check out the help flag for more commands: 121 | 122 | ```sh 123 | trex --help 124 | ``` 125 | 126 | ## Roadmap 127 | 128 | - [ ] **Editable Text Area**: Replace the read-only view with an editable interface. 129 | - [ ] **Local History**: Implement local history similar to shell history, 130 | navigable with arrow keys. 131 | - [ ] **Syntax Highlighting**: Add syntax highlighting for the RegEx input. 132 | - [ ] **Toggleable Flags**: Implement quick toggling for RegEx flags, such as: 133 | - `g` (global) 134 | - `m` (multi-line) 135 | - `i` (case-insensitive) 136 | - `U` (ungreedy) 137 | 138 | ## Contributing 139 | 140 | Contributions are welcome! Feel free to open issues or submit pull requests. 141 | For major changes, please open an issue first to discuss what you'd like to 142 | change. 143 | 144 | ## Implementation 145 | 146 | Developed in Go, **TReX** leverages: 147 | 148 | - [Bubble Tea](https://github.com/charmbracelet/bubbletea) for building the TUI. 149 | - [Cobra](https://github.com/spf13/cobra) for command-line functionality. 150 | - [Lip Gloss](https://github.com/charmbracelet/lipgloss) for styling. 151 | - [Bubbles](https://github.com/charmbracelet/bubbles) for additional utilities 152 | 153 | --- 154 | 155 |

156 | Made during 157 | FOSS HACK 2025 158 | in India :india: 159 |

160 | 161 |

162 | 163 | By 164 | Samyak Bardiya 165 | & 166 | Mital Sapkale 167 | 168 |

169 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= 2 | github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= 3 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= 4 | github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= 5 | github.com/charmbracelet/bubbles v0.20.0 h1:jSZu6qD8cRQ6k9OMfR1WlM+ruM8fkPWkHvQWD9LIutE= 6 | github.com/charmbracelet/bubbles v0.20.0/go.mod h1:39slydyswPy+uVOHZ5x/GjwVAFkCsV8IIVy+4MhzwwU= 7 | github.com/charmbracelet/bubbletea v1.3.4 h1:kCg7B+jSCFPLYRA52SDZjr51kG/fMUEoPoZrkaDHyoI= 8 | github.com/charmbracelet/bubbletea v1.3.4/go.mod h1:dtcUCyCGEX3g9tosuYiut3MXgY/Jsv9nKVdibKKRRXo= 9 | github.com/charmbracelet/lipgloss v1.0.0 h1:O7VkGDvqEdGi93X+DeqsQ7PKHDgtQfF8j8/O2qFMQNg= 10 | github.com/charmbracelet/lipgloss v1.0.0/go.mod h1:U5fy9Z+C38obMs+T+tJqst9VGzlOYGj4ri9reL3qUlo= 11 | github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2llXn7xE= 12 | github.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q= 13 | github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= 14 | github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= 15 | github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= 16 | github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= 17 | github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= 18 | github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= 19 | github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= 20 | github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= 21 | github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= 22 | github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= 23 | github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= 24 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 25 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 26 | github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= 27 | github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= 28 | github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= 29 | github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= 30 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= 31 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= 32 | github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= 33 | github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= 34 | github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= 35 | github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= 36 | github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 37 | github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= 38 | github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= 39 | github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 40 | github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA= 41 | github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= 42 | github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo= 43 | github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0= 44 | github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= 45 | github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 46 | golang.design/x/clipboard v0.7.0 h1:4Je8M/ys9AJumVnl8m+rZnIvstSnYj1fvzqYrU3TXvo= 47 | golang.design/x/clipboard v0.7.0/go.mod h1:PQIvqYO9GP29yINEfsEn5zSQKAz3UgXmZKzDA6dnq2E= 48 | golang.org/x/exp/shiny v0.0.0-20250218142911-aa4b98e5adaa h1:PplMggaL0Bbc/LKcMhOVb5jtdRZoIqqTV9X8UPLC3Yk= 49 | golang.org/x/exp/shiny v0.0.0-20250218142911-aa4b98e5adaa/go.mod h1:ygj7T6vSGhhm/9yTpOQQNvuAUFziTH7RUiH74EoE2C8= 50 | golang.org/x/image v0.24.0 h1:AN7zRgVsbvmTfNyqIbbOraYL8mSwcKncEj8ofjgzcMQ= 51 | golang.org/x/image v0.24.0/go.mod h1:4b/ITuLfqYq1hqZcjofwctIhi7sZh2WaCjvsBNjjya8= 52 | golang.org/x/mobile v0.0.0-20250218173827-cd096645fcd3 h1:0V/7Y1FEaFdAzb9DkVDh4QFp4vL4yYCiJ5cjk80lZyA= 53 | golang.org/x/mobile v0.0.0-20250218173827-cd096645fcd3/go.mod h1:j5VYNgQ6lZYZlzHFjdgS2UeqRSZunDk+/zXVTAIA3z4= 54 | golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w= 55 | golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= 56 | golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 57 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 58 | golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= 59 | golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 60 | golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM= 61 | golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= 62 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 63 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 64 | -------------------------------------------------------------------------------- /internal/ui/model.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | 7 | "github.com/charmbracelet/bubbles/list" 8 | "github.com/charmbracelet/bubbles/textinput" 9 | "github.com/charmbracelet/bubbles/viewport" 10 | tea "github.com/charmbracelet/bubbletea" 11 | "github.com/samyakbardiya/trex/internal/util" 12 | ) 13 | 14 | // focus represents the current focus state of the UI. 15 | type focus uint 16 | 17 | const ( 18 | focusInput focus = iota 19 | focusContent 20 | focusCheatsheet 21 | ) 22 | 23 | // state represents the current state of the application 24 | type state uint 25 | 26 | const ( 27 | stateActive state = iota 28 | stateNotification 29 | stateExiting 30 | ) 31 | 32 | // tickMsg represents a tick event in the application 33 | type tickMsg struct{} 34 | 35 | type keyBinding struct { 36 | description string // description provides a human-readable explanation of the binding 37 | binding string // binding represents the key sequence for this binding 38 | } 39 | 40 | type itemType uint 41 | 42 | const ( 43 | itemCheatcode itemType = iota 44 | itemTemplate 45 | ) 46 | 47 | type item struct { 48 | itemType itemType 49 | pattern string 50 | description string 51 | testStr string 52 | } 53 | 54 | func (i item) FilterValue() string { return "" } 55 | 56 | var items = []list.Item{ 57 | item{ 58 | itemType: itemTemplate, 59 | pattern: "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$", 60 | description: "Email Vali", 61 | testStr: "test@example.com\nuser.name+tag@domain.co\njohn.doe@sub.example.org\nadmin@localhost\nuser@domain.com\ninvalid@domain\n@missingusername.com\nuser@.com\nuser@domain..com\nuser@domain.c", 62 | }, 63 | item{ 64 | itemType: itemTemplate, 65 | pattern: "^(https?|ftp):\\/\\/[^\\s/$.?#].[^\\s]*$", 66 | description: "URL Vali", 67 | testStr: "http://example.com\nhttps://www.google.com\nftp://fileserver.com\nhttps://sub.domain.org/path?query=1\nhttp://localhost\ninvalid://url\nhttp:/missing.com\nhttps://\nftp:/invalid\nhttp://.com", 68 | }, 69 | item{ 70 | itemType: itemTemplate, 71 | pattern: "^\\+?[1-9]\\d{1,14}$", 72 | description: "Phone Vali", 73 | testStr: "+1234567890\n1234567890\n+19876543210\n+447911123456\n987654321\n+1\n+123456789012345\n0123456789\n+1234567890123456\n+", 74 | }, 75 | item{ 76 | itemType: itemTemplate, 77 | pattern: "^(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$", 78 | description: "IPv4 Vali", 79 | testStr: "192.168.1.1\n255.255.255.255\n0.0.0.0\n127.0.0.1\n10.0.0.1\n256.256.256.256\n192.168.1\n192.168.1.256\n192.168.1.1.1\n192.168..1", 80 | }, 81 | item{ 82 | itemType: itemTemplate, 83 | pattern: "^([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}$", 84 | description: "IPv6 Vali", 85 | testStr: "2001:0db8:85a3:0000:0000:8a2e:0370:7334\n::1\nfe80::1ff:fe23:4567:890a\n2001:db8::\n2001:db8:0:0:0:0:2:1\n2001:db8:0:0:0:0:0:1\n2001:db8::1\n2001:db8:::1\n2001:db8:85a3::8a2e:370:7334\n::", 86 | }, 87 | item{ 88 | itemType: itemTemplate, 89 | pattern: "^\\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\\d|3[01])$", 90 | description: "Date Vali", 91 | testStr: "2023-10-15\n1999-01-01\n2000-02-29\n2023-02-28\n2023-12-31\n2023-13-01\n2023-00-01\n2023-01-32\n2023-02-30\n2023-02-29", 92 | }, 93 | item{ 94 | itemType: itemTemplate, 95 | pattern: "^([01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d$", 96 | description: "Time Vali", 97 | testStr: "14:30:45\n00:00:00\n23:59:59\n12:34:56\n01:01:01\n24:00:00\n12:60:00\n12:00:60\n12:34\n123:45:67", 98 | }, 99 | item{ 100 | itemType: itemTemplate, 101 | pattern: "^([0-9A-Fa-f]{2}[:-]){5}([0-9A-Fa-f]{2})$", 102 | description: "MAC Vali", 103 | testStr: "00:1A:2B:3C:4D:5E\n00-1A-2B-3C-4D-5E\n01:23:45:67:89:AB\nAA:BB:CC:DD:EE:FF\n00:00:00:00:00:00\n00:1A:2B:3C:4D\n00:1A:2B:3C:4D:5E:6F\n00:1A:2B:3C:4D:ZZ\n00:1A:2B:3C:4D:5\n00:1A:2B:3C:4D:5E:", 104 | }, 105 | 106 | item{itemType: itemCheatcode, pattern: "\\n", description: "Newline"}, 107 | item{itemType: itemCheatcode, pattern: "\\t", description: "Tab"}, 108 | item{itemType: itemCheatcode, pattern: "\\r", description: "Carriage return"}, 109 | item{itemType: itemCheatcode, pattern: ".", description: "Any single character"}, 110 | item{itemType: itemCheatcode, pattern: "\\s", description: "Any whitespace character"}, 111 | item{itemType: itemCheatcode, pattern: "\\S", description: "Any non-whitespace character"}, 112 | item{itemType: itemCheatcode, pattern: "\\d", description: "Any digit"}, 113 | item{itemType: itemCheatcode, pattern: "\\D", description: "Any non-digit"}, 114 | item{itemType: itemCheatcode, pattern: "\\w", description: "Any word character"}, 115 | item{itemType: itemCheatcode, pattern: "\\W", description: "Any non-word character"}, 116 | item{itemType: itemCheatcode, pattern: "^", description: "Start of string"}, 117 | item{itemType: itemCheatcode, pattern: "$", description: "End of string"}, 118 | item{itemType: itemCheatcode, pattern: "\\b", description: "A word boundary"}, 119 | item{itemType: itemCheatcode, pattern: "\\B", description: "Non-word boundary"}, 120 | item{itemType: itemCheatcode, pattern: "a*", description: "Zero or more of a"}, 121 | item{itemType: itemCheatcode, pattern: "a+", description: "One or more of a"}, 122 | item{itemType: itemCheatcode, pattern: "a?", description: "Zero or one of a"}, 123 | item{itemType: itemCheatcode, pattern: "a{3,6}", description: "Between 3 and 6 of a"}, 124 | item{itemType: itemCheatcode, pattern: "[a-z]", description: "A character in the range: a-z"}, 125 | item{itemType: itemCheatcode, pattern: "[a-zA-Z]", description: "A character in the range: a-z or A-Z"}, 126 | item{itemType: itemCheatcode, pattern: "[[:digit:]]", description: "Decimal digits"}, 127 | item{itemType: itemCheatcode, pattern: "[[:alnum:]]", description: "Letters and digits"}, 128 | item{itemType: itemCheatcode, pattern: "[[:alpha:]]", description: "Letters"}, 129 | item{itemType: itemCheatcode, pattern: "[[:lower:]]", description: "Lowercase letters"}, 130 | item{itemType: itemCheatcode, pattern: "[[:upper:]]", description: "Uppercase letters"}, 131 | item{itemType: itemCheatcode, pattern: "[[:space:]]", description: "Whitespace"}, 132 | item{itemType: itemCheatcode, pattern: "[[:print:]]", description: "Visible characters"}, 133 | item{itemType: itemCheatcode, pattern: "[[:graph:]]", description: "Visible characters (not space)"}, 134 | item{itemType: itemCheatcode, pattern: "[[:punct:]]", description: "Visible punctuation characters"}, 135 | item{itemType: itemCheatcode, pattern: "[[:xdigit:]]", description: "Hexadecimal digits"}, 136 | item{itemType: itemCheatcode, pattern: "\\f", description: "Insert a form-feed"}, 137 | item{itemType: itemCheatcode, pattern: "[abc]", description: "A single character of: a, b or c"}, 138 | item{itemType: itemCheatcode, pattern: "[^abc]", description: "A character except: a, b or c"}, 139 | item{itemType: itemCheatcode, pattern: "[^a-z]", description: "A character not in the range: a-z"}, 140 | item{itemType: itemCheatcode, pattern: "a|b", description: "Alternate - match either a or b"}, 141 | item{itemType: itemCheatcode, pattern: "(...)", description: "Capturing group"}, 142 | item{itemType: itemCheatcode, pattern: "(?:...)", description: "Non-capturing group"}, 143 | item{itemType: itemCheatcode, pattern: "(?P...)", description: "Named Capturing Group"}, 144 | item{itemType: itemCheatcode, pattern: "g", description: "Global"}, 145 | item{itemType: itemCheatcode, pattern: "m", description: "Multiline"}, 146 | item{itemType: itemCheatcode, pattern: "i", description: "Case insensitive"}, 147 | item{itemType: itemCheatcode, pattern: "s", description: "Single line"}, 148 | item{itemType: itemCheatcode, pattern: "a*", description: "Greedy quantifier"}, 149 | item{itemType: itemCheatcode, pattern: "U", description: "Ungreedy"}, 150 | item{itemType: itemCheatcode, pattern: "\\A", description: "Start of string"}, 151 | item{itemType: itemCheatcode, pattern: "\\z", description: "Absolute end of string"}, 152 | item{itemType: itemCheatcode, pattern: "\\xYY", description: "Hex character YY"}, 153 | item{itemType: itemCheatcode, pattern: "\\x{YYYY}", description: "Hex character YYYY"}, 154 | item{itemType: itemCheatcode, pattern: "\\ddd", description: "Octal character ddd"}, 155 | item{itemType: itemCheatcode, pattern: "\\pX", description: "Unicode property X"}, 156 | item{itemType: itemCheatcode, pattern: "\\p{...}", description: "Unicode property or script category"}, 157 | item{itemType: itemCheatcode, pattern: "\\PX", description: "Negation of \\pX"}, 158 | item{itemType: itemCheatcode, pattern: "\\P{...}", description: "Negation of \\p"}, 159 | item{itemType: itemCheatcode, pattern: "\\Q...\\E", description: "Quote; treat as literals"}, 160 | item{itemType: itemCheatcode, pattern: "[[:cntrl:]]", description: "Control characters"}, 161 | item{itemType: itemCheatcode, pattern: "[[:ascii:]]", description: "ASCII codes 0-127"}, 162 | item{itemType: itemCheatcode, pattern: "[[:blank:]]", description: "Space or tab only"}, 163 | item{itemType: itemCheatcode, pattern: "\\v", description: "Vertical whitespace character"}, 164 | item{itemType: itemCheatcode, pattern: "\\", description: "Makes any character literal"}, 165 | item{itemType: itemCheatcode, pattern: "a{3}", description: "Exactly 3 of a"}, 166 | item{itemType: itemCheatcode, pattern: "a{3,}", description: "3 or more of a"}, 167 | item{itemType: itemCheatcode, pattern: "\\0", description: "Complete match contents"}, 168 | item{itemType: itemCheatcode, pattern: "\\1", description: "Contents in capture group 1"}, 169 | item{itemType: itemCheatcode, pattern: "$1", description: "Contents in capture group 1"}, 170 | item{itemType: itemCheatcode, pattern: "${foo}", description: "Contents in capture group `foo"}, 171 | item{itemType: itemCheatcode, pattern: "\\x{06fa}", description: "Hexadecimal replacement values"}, 172 | item{itemType: itemCheatcode, pattern: "\\u06fa", description: "Hexadecimal replacement values"}, 173 | } 174 | 175 | type itemDelegate struct { 176 | focus focus 177 | } 178 | 179 | func (d itemDelegate) Height() int { return 1 } 180 | func (d itemDelegate) Spacing() int { return 0 } 181 | func (d itemDelegate) Update(_ tea.Msg, _ *list.Model) tea.Cmd { return nil } 182 | func (d itemDelegate) Render(w io.Writer, m list.Model, index int, listItem list.Item) { 183 | i, ok := listItem.(item) 184 | if !ok { 185 | return 186 | } 187 | 188 | var str []string 189 | switch i.itemType { 190 | case itemCheatcode: 191 | description := tsCheatsheetDescription.Render(i.description) 192 | pattern := tsCheatcode.Render(fmt.Sprintf(" %4s ", i.pattern)) 193 | str = []string{pattern, description} 194 | case itemTemplate: 195 | pattern := tsTemplate.Render(fmt.Sprintf(" %4s ", i.pattern)) 196 | description := tsCheatsheetDescription.Render(i.description) 197 | str = []string{description, pattern} 198 | } 199 | 200 | width := m.Width() 201 | style := tsListDefault 202 | if d.focus == focusCheatsheet && index == m.Index() { 203 | style = tsListSelected 204 | } 205 | fmt.Fprint(w, style.MaxWidth(width).Render(str...)) 206 | } 207 | 208 | type model struct { 209 | state state // current state of the application 210 | focus focus // current focus of the UI 211 | matchRes util.MatchResult // result of the regex matching operation 212 | input textinput.Model // model for handling input 213 | viewport viewport.Model // model for handling content 214 | cheatsheet list.Model // model for handling cheatsheet 215 | width int // width of the window 216 | height int // height of the window 217 | time tickMsg // tracks tick events for state transitions 218 | err error // any error encountered during application execution 219 | } 220 | 221 | func New(initialContent string) model { 222 | in := textinput.New() 223 | in.Placeholder = "RegEx" 224 | in.Prompt = "" 225 | in.Focus() 226 | 227 | ch := list.New(items, itemDelegate{focus: focusInput}, minWidth*rightWidthRatio, 48) 228 | ch.SetFilteringEnabled(false) 229 | ch.SetShowFilter(false) 230 | ch.SetShowHelp(false) 231 | ch.SetShowStatusBar(false) 232 | ch.SetShowTitle(false) 233 | ch.Styles.PaginationStyle = tsNormal 234 | ch.Styles.HelpStyle = tsHelp 235 | 236 | vp := viewport.New(minWidth*leftWidthRatio, minHeight) 237 | vp.SetContent(initialContent) 238 | 239 | return model{ 240 | state: stateActive, 241 | focus: focusInput, 242 | matchRes: util.MatchResult{ 243 | InputText: initialContent, 244 | Highlighted: initialContent, 245 | }, 246 | input: in, 247 | viewport: vp, 248 | cheatsheet: ch, 249 | err: nil, 250 | } 251 | } 252 | 253 | func (m model) Init() tea.Cmd { 254 | return textinput.Blink 255 | } 256 | --------------------------------------------------------------------------------