diff --git a/content/posts/matrix-go.md b/content/posts/matrix-go.md new file mode 100644 index 0000000..f361fe8 --- /dev/null +++ b/content/posts/matrix-go.md @@ -0,0 +1,486 @@ +--- +title: "matrix.go" +date: 2025-02-05T14:00:00Z +draft: false +tags: ["go", "bubbletea", "terminal", "tui"] +--- + +Welcome to our implementation of what we imagined will be a fun project of making rain in the terminal. Then we couldn't figure out how so we asked [opencode](https://opencode.ai/). The following is what we (us and OC) came up with. + +## Let's Begin +We are using the approach of analyzing code snippets in order of the flow. Usage of what and why is done for each part of the code explanation. + +## Initialisation + +`//go:embed` directive allows programs to include arbitrary files in the go binary at build time. + +```go +//go:embed assets/*.txt +var assetsFS embed.FS +``` + +We use this to include all the text files from the `assets/` directory (used for the text for rain). All values are stored in the variable `assetsFS` of type [`embed.FS`](https://pkg.go.dev/embed#FS), a read-only collection of files (allowing for use with multiple go-routines). This is useful since we can use the `ReadFile` function to return the content of the files which we ship with the binary. + +```go +const listHeight = 14 + +var ( + titleStyle = lipgloss.NewStyle().MarginLeft(2) + itemStyle = lipgloss.NewStyle().PaddingLeft(4) + selectedItemStyle = lipgloss.NewStyle().PaddingLeft(2).Foreground(lipgloss.Color("170")) + paginationStyle = list.DefaultStyles().PaginationStyle.PaddingLeft(4) + helpStyle = list.DefaultStyles().HelpStyle.PaddingLeft(4).PaddingBottom(1) + quitTextStyle = lipgloss.NewStyle().Margin(1, 0, 2, 4) +) +``` + +This is used downstream to change the "style" of the list component from bubbles. The way the API is structured one cannot pass custom styles at the point of creating the component, therefore we must create a new instance of "style" using `lipgloss.NewStyle()` and then pass it to the appropriate variable in `myList.Style`. + +`type item string` + +Here we define an item of type String. + +`func (i item) FilterValue() string { return "" }` + +The `FilterValue()` method is used to filter between each of the items. We need this because it is required by the `list.Item` interface. Even though we will later disable filtering (`l.SetFilteringEnabled(false)`), the interface still requires this method to be implemented. Without it, the code won't compile. + +```go +func (d itemDelegate) Height() int { return 1 } +func (d itemDelegate) Spacing() int { return 0 } +func (d itemDelegate) Update(_ tea.Msg, _ *list.Model) tea.Cmd { return nil } +``` + +The `itemDelegate` struct implements the `list.ItemDelegate`. This interface allows us to customize how individual items are displayed and behave within a list component. +- `Height()` defines the vertical height of each list item (1 in our case). +- `Spacing()` is used to add blank lines appear between list items. (0 to make it compact). +- `Update()` handles messages specific to individual list items. Parameters supplied here are `tea.Msg` and `*list.Model` which we will learn about later. + +```go +func (d itemDelegate) Render(w io.Writer, m list.Model, index int, listItem list.Item) { + i, ok := listItem.(item) + if !ok { + return + } + + str := fmt.Sprintf("%d. %s", index+1, i.title) + + fn := itemStyle.Render + if index == m.Index() { + fn = func(s ...string) string { + return selectedItemStyle.Render("> " + strings.Join(s, " ")) + } + } + + fmt.Fprint(w, fn(str)) +} +``` + +This contains various parameters of types such as `itemDelegate` has `d` which is the receiver to struct (covered earlier). We supply the output through `io.Writer` where *rendered* text will be written. +- `list.Model` displays the parent list state (which item is selected). + +`i, ok := listItem.(item)` +This is Go's type assertion feature. `list.Item` allows us to access fields like `.title` and `.filename`. If value of `i` is returned true, conversion has succeeded otherwise it means the type is wrong. + +`str := fmt.Sprintf("%d. %s", index+1, i.title)` +Here, we format the display string for providing us with options. `i.title` is used to display the form of text displayed (Matrix, Great Work, etc). We use `index+1` to add human friendly numbering beginning from 1. + +`fn := itemStyle.Render` +We use `.Render` for an item style that takes a string and returns a *styled* string. +`m.Index()` simply returns the currently selected item's index in the list. When selected, `fn` is triggered to add variadic parameter in `s ...string`. + +`selectedItemStyle.Render("> " + strings.Join(s, " "))` +`Render` applies the selected item style (purple color + 2 spaces padding). +Under `strings.Join()`, we simply prepend the arrow to the joined string to produce an output such as : `"> 1. Great Work"`. + +`fmt.Fprint(w, fn(str))` +Selects `fn` containing the formatted string and returns a styled version of it. +`w` writes the string to `io.Writer` for obtaining output. + +```go +func (m model) Init() tea.Cmd { + if m.viewing { + return tick() + } + return nil +} + +func tick() tea.Cmd { + return tea.Tick(time.Millisecond*80, func(t time.Time) tea.Msg { + return tickMsg(t) + }) +} + +``` + +From a broad view, these two functions work together to implement animation timing in the Bubble Tea framework. They create a recurring "heartbeat" that drives the matrix rain effect. + +`return tick` performs on the boolean true result of `m.viewing`. tick starts the animation otherwise returns nil if boolean false. + +`tick() tea.Cmd` has a command output structure. It waits 80 milliseconds, sends a `tickMsg` back to the application and triggers next animation frame. 80ms is the sweet spot as it is smooth and consumes less CPU resources. `tickMsg` is a basic signal that says "show next frame". + +```go +func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + if m.list.Items() != nil { + m.list.SetWidth(msg.Width) + } + if m.viewing { + m.initColumns() + } + return m, nil +``` + +We implement the `Update` method here to process every single thing that happens (key press, window resize, timer tick, etc). To figure out the exact kind of message sent, we implement the `switch msg := msg.(type)`. In this particular case, we work with `tea.WindowSizeMsg` focusing on dragging to the corner to make it bigger or smaller via `msg.Width` and `msg Height` which adapts accordingly. Next we check whether it is in `m.viewing` mode, if it is, the Columns are recalculated based on new dimensions. + +```go +case tea.KeyMsg: + switch keypress := msg.String(); keypress { + case "q", "ctrl+c": + if m.viewing { + // If there's no list (direct mode), quit entirely + if m.list.Items() == nil { + m.quitting = true + return m, tea.Quit + } + // Otherwise return to list + m.viewing = false + m.choice = "" + return m, nil + } + m.quitting = true + return m, tea.Quit + + case "enter": + if !m.viewing && m.list.Items() != nil { + i, ok := m.list.SelectedItem().(item) + if ok { + m.choice = i.filename + content, err := readAsset(m.choice) + if err != nil { + m.content = fmt.Sprintf("Error: %v", err) + } else { + m.content = content + } + m.viewing = true + m.initColumns() + return m, tick() + } + } + } + + case tickMsg: + if m.viewing { + m.updateColumns() + return m, tick() + } + } + + var cmd tea.Cmd + if m.list.Items() != nil { + m.list, cmd = m.list.Update(msg) + } + return m, cmd +} +``` + +Here we have a set of cases defining various actions. `case "q", "ctrl+c":` simply quits the instance and clears the screen, but we check whether is being viewed or not first. If the `m.quitting` is true, `View()` shows the quit message `Quitting is for losers!` (which is indeed true). + +`case "enter":` +When you press Enter, it first checks whether it is being viewed and has a list, This makes sure we're in the file selection screen where Enter should actually do something. If both conditions are true, it tries to get the currently selected item from the list using `m.list.SelectedItem().(item)`. That type assertion might fail if something weird happened, we use the `, ok` idiom to safely check. If it successfully got the selected item, now the fun begins. We grab the filename (`i.filename`), try to read the file's content using `readAsset()`, and handle any errors gracefully by storing a `Error : %v` message. + +Every 80 milliseconds while the animation is running, it receives a `tickMsg`. This is the cue to advance the animation by one frame. It checks `if m.viewing` to make sure we're still in viewing mode (the user might have pressed 'q' to go back to the list), and if so, it calls `m.updateColumns()` which moves each column's offset forward, creating the scrolling effect. + +```go +func (m *model) initColumns() { + if m.width == 0 || len(m.content) == 0 { + return + } + + numCols := m.width / 2 + if numCols < 1 { + numCols = 1 + } + + m.columns = make([]column, numCols) + runes := []rune(m.content) + + for i := range m.columns { + m.columns[i] = column{ + x: i * 2, + height: rand.Intn(m.height) + 5, + offset: rand.Intn(len(runes)), + } + } +} + +func (m *model) updateColumns() { + runes := []rune(m.content) + if len(runes) == 0 { + return + } + + for i := range m.columns { + m.columns[i].offset++ + if m.columns[i].offset >= len(runes) { + m.columns[i].offset = 0 + } + + if rand.Float32() < 0.02 { + m.columns[i].height = rand.Intn(m.height) + 5 + } + } +} +``` + +`initColumns()` sets up all the columns first -- it's like setting the stage before show begins. For sanity check, we use `m.width == 0 || len(m.content) == 0`. We then divide the terminal width by 2 (`numCols := m.width / 2`). Why 2? Because we want to space the columns out, creating gaps between cascading characters. + +Next, we allocate memory for all these columns using `make([]column, numCols)`. We then convert the entire file content into runes `[]rune(m.content)` to properly handle emojis, Chinese, Arabic characters or other Unicode content. +`rand.Intn(m.height)` gives a random number between 0 and the terminal height, then we add 5 to ensure every column is at least 5 characters tall. The *offset* is also randomized `rand.Intn(len(runes))`. This determines where in the text file each column starts reading. + +Then we loop through every single column created and update it. For each column, we increment its offset by `m.columns[i].offset++`. Now, if the offset has reached or exceeded the length of the runes array, we wrap it back to zero (`m.columns[i].offset = 0`). This creates an infinite loop through the content. It's like a circular buffer. + +Every frame, for each column, we roll the dice with a 2% chance `rand.Float32() < 0.02`. Think about it: `rand.Float32()` gives me a random number between 0.0 and 1.0. + +```go +func (m model) View() string { + if m.viewing { + return m.matrixView() + } + if m.quitting { + return quitTextStyle.Render("Quitting is for losers.") + } + if m.list.Items() != nil { + return "\n" + m.list.View() + } + return "" +} + +func (m model) matrixView() string { + if len(m.columns) == 0 || len(m.content) == 0 { + return "" + } + + grid := make([][]rune, m.height) + for i := range grid { + grid[i] = make([]rune, m.width) + for j := range grid[i] { + grid[i][j] = ' ' + } + } + + runes := []rune(m.content) + + for _, col := range m.columns { + if col.x >= m.width { + continue + } + + for row := 0; row < col.height && row < m.height; row++ { + charIdx := (col.offset + row) % len(runes) + if charIdx < 0 { + charIdx += len(runes) + } + + if row < m.height && col.x < m.width { + grid[row][col.x] = runes[charIdx] + } + } + } + + var result strings.Builder + for rowIdx, row := range grid { + for _, char := range row { + if char != ' ' { + color := getRandomColor() + style := lipgloss.NewStyle().Foreground(lipgloss.Color(color)) + result.WriteString(style.Render(string(char))) + } else { + result.WriteRune(' ') + } + } + if rowIdx < len(grid)-1 { + result.WriteString("\n") + } + } + + return result.String() +} +``` + +Further, we proceed to displaying the rain on terminal screen. We achieve this by using `View()` with various conditions. If `m.viewing` is true, the `matrixView()` creates the rain effect, if not we let the user quit because we assume they are a loser xD. If the user is not viewing and not quitting, then they must be in list mode. We check if the list exists `m.list.Items() != nil`, and if so, return the list's view with a newline prepended. + +Now, here's the big idea: We create a 2D grid that represents every single character position on the screen. Think of it like graph paper where each cell can hold one character. We create this grid using `make([][]rune, m.height)` which gives a slice of slices - essentially a 2D array where the first dimension is rows (height) and the second is columns (width). + +We iterate through all the columns. For each column, we first check if its x-position is off the screen (`col.x >= m.width`). If someone resized the window smaller after the columns were created, some columns might be positioned beyond the right edge. We skip those with `continue` because trying to draw them would cause an out-of-bounds error. + +Next, we initialize this entire grid to spaces. Allocate space for `m.width` and fill all positions with `' '`. This creates a blank canvas. Let's break down `(col.offset + row) % len(runes)`. On adding the offset and row we get a position in the source text. The `%` wraps the index around if it exceeds the text length. This creates that circular buffer effect where columns seamlessly loop through the content. + +We then use a `strings.Builder` which is Go's efficient way of building strings piece by piece. If the character is NOT a space, we generate a random color, create a [Lipgloss](https://pkg.go.dev/github.com/charmbracelet/lipgloss#section-readme) style with that color, and render the character with that styling. Finally, `result.String` gives us the beautiful rain effect as a single string! + +```go +func getRandomColor() string { + colors := []string{ + "196", "197", "198", "199", "200", "201", // pinks/magentas + "160", "161", "162", "163", "164", "165", // reds + "202", "203", "204", "205", "206", "207", // oranges + "208", "209", "210", "211", "212", "213", // light oranges + "214", "215", "216", "217", "218", "219", // yellows + "220", "221", "222", "223", "224", "225", // light yellows + "226", "227", "228", "229", "230", "231", // whites + "82", "83", "84", "85", "86", "87", // greens + "28", "29", "30", "31", "32", "33", // dark greens + "40", "41", "42", "43", "44", "45", // cyans + "46", "47", "48", "49", "50", "51", // teals + "75", "76", "77", "78", "79", "80", // blues + "63", "64", "65", "66", "67", "68", // dark blues + "90", "91", "92", "93", "94", "95", // purples + "129", "130", "131", "132", "133", "134", // violets + "141", "142", "143", "144", "145", "146", // light purples + } + return colors[rand.Intn(len(colors))] +} + +func readAsset(filename string) (string, error) { + data, err := assetsFS.ReadFile(filename) + if err != nil { + return "", err + } + return string(data), nil +} + +func getAvailableFiles() []string { + files := []string{} + entries, err := assetsFS.ReadDir("assets") + if err != nil { + return files + } + for _, entry := range entries { + if !entry.IsDir() && strings.HasSuffix(entry.Name(), ".txt") { + files = append(files, "assets/"+entry.Name()) + } + } + return files +} +``` + +`getRandomColor()` maintains a carefully curated palette of 96 colors, organized into logical groups spanning the full spectrum. The selection mechanism is elegantly simple: `rand.Intn(len(colors))` generates a random integer between zero and the length of the slice, which then serves as an index to retrieve a color code. + + The `readAsset()` function is the gateway to loading resources **efficiently**, this reads a file from an **embedded filesystem** `assetsFS` and returns its contents as a string. If the file can’t be read, it propagates the error for graceful handling. `ReadFile(filename string)` +Attempts to read the specified file from the embedded filesystem. + +`!entry.IsDir()` → Ensures we only process files, not subdirectories. For each valid file, it constructs the full path: `"assets/" + entry.Name()` + +```go +func main() { + rand.Seed(time.Now().UnixNano()) + + var showOptions bool + var filePath string + flag.BoolVar(&showOptions, "options", false, "Show list of available files to choose from") + flag.StringVar(&filePath, "file", "", "Path to a specific .txt file to display") + flag.Parse() + + // Default: show matrix directly with greatwork.txt + targetFile := "assets/greatwork.txt" + + if filePath != "" { + // User specified a file + targetFile = filePath + // If it's not a path with assets/, assume it's in assets/ + if !strings.Contains(targetFile, "/") && !strings.HasPrefix(targetFile, "assets/") { + targetFile = "assets/" + targetFile + } + } + + if showOptions { + // Show list widget + files := getAvailableFiles() + if len(files) == 0 { + fmt.Println("No .txt files found in assets") + os.Exit(1) + } + + var items []list.Item + for _, f := range files { + title := f + // Convert filename to friendly title + title = strings.TrimPrefix(title, "assets/") + title = strings.TrimSuffix(title, ".txt") + title = strings.ReplaceAll(title, "-", " ") + // Capitalize words + words := strings.Split(title, " ") + for i, word := range words { + if len(word) > 0 { + words[i] = strings.ToUpper(word[:1]) + word[1:] + } + } + title = strings.Join(words, " ") + items = append(items, item{title: title, filename: f}) + } + + const defaultWidth = 20 + l := list.New(items, itemDelegate{}, defaultWidth, listHeight) + l.Title = "Choose text:" + l.SetShowStatusBar(false) + l.SetFilteringEnabled(false) + l.Styles.Title = titleStyle + l.Styles.PaginationStyle = paginationStyle + l.Styles.HelpStyle = helpStyle + + m := model{list: l} + + if _, err := tea.NewProgram(m).Run(); err != nil { + fmt.Println("Error running program:", err) + os.Exit(1) + } + } else { + // Direct matrix view + content, err := readAsset(targetFile) + if err != nil { + // Try reading from filesystem as fallback + data, err2 := os.ReadFile(targetFile) + if err2 != nil { + fmt.Printf("Error reading file %s: %v\n", targetFile, err) + os.Exit(1) + } + content = string(data) + } + + m := model{ + content: content, + viewing: true, + } + + if _, err := tea.NewProgram(m).Run(); err != nil { + fmt.Println("Error running program:", err) + os.Exit(1) + } + } +} +``` + +The very first line does something subtle but important: `rand.Seed(time.Now().UnixNano())` . We're seeding Go's random number generator with the current time in nanoseconds. That cascading matrix effect needs unpredictability to feel alive and organic. By using the current timestamp, we ensure that each run produces a unique visual experience. + +Next, we set up our command-line flags - the arguments users can pass when launching the program: `var showOptions bool`, giving us three ways to run it: +1. Simple Launch via `./program`. +2. File Selection Mode `./program --options` +3. Direct File Mode `./program --file philosophy.txt` + +If someone types `./program --file mytext.txt`, we automatically assume they mean `assets/mytext.txt`. But if they provide a full path like `/home/user/documents/poem.txt`, we use it exactly as given. When the user launches with `--options`, we enter list mode under `showOptions()`. + +A file named `assets/the-philosophy-of-time.txt` becomes "The Philosophy Of Time" in the display by using `strings.ToUpper(word[:1]) + word[1:]`. We're not just showing raw filenames - we're presenting them in a way that respects the user. + +- Building the List Widget: + - `const defaultWidth = 20 + - `l := list.New(items, itemDelegate{}, defaultWidth, listHeight)` +We're configuring the Bubble Tea list component with sensible defaults. Filtering is off because with a handful of text files, simple arrow key navigation suffices. The styling assignments connect our global style definitions (like `titleStyle` and `paginationStyle`) to the list component. + +- Launching the List Experience: + - `m := model{list: l}` +We create a model with just the list (no content, not viewing yet), wrap it in a Bubble Tea program, and run it. The program blocks here, handling all user interaction, until the user quits or selects a file. + +*The goal we should all strive for: code that works correctly, fails gracefully, and reads like a story. When someone new comes to this codebase, they can read `main()` top to bottom and understand exactly what the program does and how to use it.* +