diff --git a/cmd/root.go b/cmd/root.go index ceab267..dda087b 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -6,6 +6,7 @@ import ( "os" "os/exec" "runtime" + "strconv" "strings" tea "github.com/charmbracelet/bubbletea" @@ -96,11 +97,19 @@ func ReadFlags(cmd *cobra.Command) { fmt.Fprintf(os.Stderr, "Error getting current working directory: %v\n", err) os.Exit(1) } + // Get terminal dimensions dynamically + termWidth, termHeight, err := getTerminalSize() + if err != nil { + fmt.Fprintf(os.Stderr, "Error getting terminal size: %v\n", err) + os.Exit(1) + } // Initialize your model with the current directory model := tui.Model{ State: "FileSelection", - FilesSelector: modelutils.InitialModel(currentDir, 20), + FilesSelector: modelutils.InitialModel(currentDir, termHeight, termWidth), + Width: termWidth, + Height: termHeight, } clearScreen() // Bubble Tea program @@ -212,3 +221,28 @@ func clearScreen() { cmd.Stdout = os.Stdout cmd.Run() } +func getTerminalSize() (width, height int, err error) { + cmd := exec.Command("tput", "cols") + cmd.Stdin = os.Stdin + out, err := cmd.Output() + if err != nil { + return 0, 0, err + } + width, err = strconv.Atoi(strings.TrimSpace(string(out))) + if err != nil { + return 0, 0, err + } + + cmd = exec.Command("tput", "lines") + cmd.Stdin = os.Stdin + out, err = cmd.Output() + if err != nil { + return 0, 0, err + } + height, err = strconv.Atoi(strings.TrimSpace(string(out))) + if err != nil { + return 0, 0, err + } + + return width, height, nil +} diff --git a/utils/server/server.go b/utils/server/server.go index ea7defd..68a0b1e 100644 --- a/utils/server/server.go +++ b/utils/server/server.go @@ -44,7 +44,9 @@ func StartServer() { // Initialize the file selector model with the directory argument model := tui.Model{ State: "FileSelection", - FilesSelector: modelutils.InitialModel(dir, pty.Window.Height-5), // Initialize the FilesSelector model with window height + FilesSelector: modelutils.InitialModel(dir, pty.Window.Height-5, pty.Window.Width-5), + Height: pty.Window.Height, + Width: pty.Window.Width, } if model.Error != nil { wish.Println(s, model.Error.Error()) diff --git a/utils/tui/model.go b/utils/tui/model.go index 5b5ee89..d413b31 100644 --- a/utils/tui/model.go +++ b/utils/tui/model.go @@ -6,6 +6,7 @@ import ( "strings" tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" "github.com/dyne/tgcom/utils/modfile" "github.com/dyne/tgcom/utils/tui/modelutils" ) @@ -20,6 +21,8 @@ type Model struct { LabelType []bool CurrentDir string // Current directory for file selection Error error + Width int + Height int // Models for different selection steps FilesSelector modelutils.FilesSelector @@ -46,17 +49,16 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } switch msg := msg.(type) { + case tea.KeyMsg: + if m.State == "Final" { + return m, tea.Quit + } case applyChangesMsg: if msg.err != nil { m.Error = msg.err } m.State = "Final" return m, nil - - case tea.KeyMsg: - if m.State == "Final" { - return m, tea.Quit - } } switch m.State { @@ -68,6 +70,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.Error = m.FilesSelector.Error return m, tea.Quit } + m.Width = m.FilesSelector.WindowWidth + m.Height = m.FilesSelector.WindowHeight m.Files = m.FilesSelector.FilesPath if len(m.Files) == 1 { m.SpeedSelector = modelutils.ModeSelector{ @@ -77,11 +81,10 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { Speed: "", } m.State = "ActionSelection" - m.ActionSelector = modelutils.NewModeSelector([]string{"toggle", "comment", "uncomment"}, filepath.Base(m.Files[0]), m.SpeedSelector.Selected) + m.ActionSelector = modelutils.NewModeSelector([]string{"toggle", "comment", "uncomment"}, filepath.Base(m.Files[0]), m.SpeedSelector.Selected, m.Width, m.Height) } else { - m.State = "ModeSelection" - m.SpeedSelector = modelutils.NewModeSelector([]string{"Fast mode", "Slow mode"}, "", "") + m.SpeedSelector = modelutils.NewModeSelector([]string{"Fast mode", "Slow mode"}, "", "", m.Width, m.Height) } } return m, cmd @@ -95,7 +98,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } if m.SpeedSelector.Done { m.State = "ActionSelection" - m.ActionSelector = modelutils.NewModeSelector([]string{"toggle", "comment", "uncomment"}, filepath.Base(m.Files[0]), m.SpeedSelector.Selected) + m.ActionSelector = modelutils.NewModeSelector([]string{"toggle", "comment", "uncomment"}, filepath.Base(m.Files[0]), m.SpeedSelector.Selected, m.Width, m.Height) } return m, cmd @@ -113,18 +116,16 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.ActionSelector.Done = false m.Actions = m.Actions[:len(m.Actions)-1] m.State = "ActionSelection" - m.ActionSelector = modelutils.NewModeSelector([]string{"toggle", "comment", "uncomment"}, filepath.Base(m.Files[len(m.Actions)]), m.SpeedSelector.Selected) - + m.ActionSelector = modelutils.NewModeSelector([]string{"toggle", "comment", "uncomment"}, filepath.Base(m.Files[len(m.Actions)]), m.SpeedSelector.Selected, m.Width, m.Height) } } if m.ActionSelector.Done { m.Actions = append(m.Actions, m.ActionSelector.Selected) if len(m.Actions) == len(m.Files) { m.State = "LabelInput" - m.LabelInput = modelutils.NewLabelInput(filepath.Base(m.Files[0])) + m.LabelInput = modelutils.NewLabelInput(filepath.Base(m.Files[0]), m.Width, m.Height) } else { - m.ActionSelector = modelutils.NewModeSelector([]string{"toggle", "comment", "uncomment"}, filepath.Base(m.Files[len(m.Actions)]), m.SpeedSelector.Selected) - + m.ActionSelector = modelutils.NewModeSelector([]string{"toggle", "comment", "uncomment"}, filepath.Base(m.Files[len(m.Actions)]), m.SpeedSelector.Selected, m.Width, m.Height) } } return m, cmd @@ -133,6 +134,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.ActionSelector = newActionSelector.(modelutils.ModeSelector) if m.ActionSelector.Back { if len(m.Files) == 1 { + if !m.FilesSelector.MultipleSelection { + m.FilesSelector.FilesPath = []string{} + } m.State = "FileSelection" m.FilesSelector.Done = false } else { @@ -146,7 +150,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.Actions = append(m.Actions, m.ActionSelector.Selected) } m.State = "LabelInput" - m.LabelInput = modelutils.NewLabelInput("") + m.LabelInput = modelutils.NewLabelInput("", m.Width, m.Height) } return m, cmd } @@ -167,7 +171,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.Labels = m.Labels[:len(m.Labels)-1] m.LabelType = m.LabelType[:len(m.LabelType)-1] m.State = "LabelInput" - m.LabelInput = modelutils.NewLabelInput(filepath.Base(m.Files[len(m.Labels)])) + m.LabelInput = modelutils.NewLabelInput(filepath.Base(m.Files[len(m.Labels)]), m.Width, m.Height) } } if m.LabelInput.Done { @@ -181,8 +185,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.State = "ApplyChanges" return m, m.applyChanges() } else { - m.LabelInput = modelutils.NewLabelInput(filepath.Base(m.Files[len(m.Labels)])) - + m.LabelInput = modelutils.NewLabelInput(filepath.Base(m.Files[len(m.Labels)]), m.Width, m.Height) } } return m, cmd @@ -218,24 +221,39 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // View renders the view based on the current state func (m Model) View() string { + var rightPane string + fileSelectionPane := m.FilesSelector.View() + var halfWidth int switch m.State { case "FileSelection": - return m.FilesSelector.View() + return fileSelectionPane case "ModeSelection": - return m.SpeedSelector.View() + halfWidth = m.SpeedSelector.Width + rightPane = m.SpeedSelector.View() case "ActionSelection": - return m.ActionSelector.View() + halfWidth = m.ActionSelector.Width + rightPane = m.ActionSelector.View() case "LabelInput": - return m.LabelInput.View() + halfWidth = m.LabelInput.Width + rightPane = m.LabelInput.View() case "ApplyChanges": - return "Applying changes..." + rightPane = "Applying changes..." case "Final": if m.Error != nil { - return modelutils.Paint("red").Render(fmt.Sprintf("An error occurred: %v\nPress any key to exit.", m.Error)) + rightPane = modelutils.Paint("red").Render(fmt.Sprintf("An error occurred: %v\nPress any key to exit.", m.Error)) + } else { + rightPane = "Changes applied successfully!\nPress any key to exit." } - return "Changes applied successfully!\nPress any key to exit." } - return "" + + // Use a style for the layout + layout := lipgloss.JoinHorizontal( + lipgloss.Top, + lipgloss.NewStyle().Width(halfWidth).Render(fileSelectionPane), + lipgloss.NewStyle().Width(halfWidth).Render(rightPane), + ) + + return layout } // applyChanges applies changes to selected files based on user inputs diff --git a/utils/tui/model_test.go b/utils/tui/model_test.go index e5fadc1..0a0ca95 100644 --- a/utils/tui/model_test.go +++ b/utils/tui/model_test.go @@ -12,8 +12,13 @@ import ( ) func TestModel(t *testing.T) { + tempDir := t.TempDir() + tempFile := filepath.Join(tempDir, "file.txt") + _, err := os.Create(tempFile) + assert.NoError(t, err) + t.Run("Init", func(t *testing.T) { - model := Model{FilesSelector: modelutils.InitialModel(".", 10)} + model := Model{FilesSelector: modelutils.InitialModel(tempDir, 10, 10)} cmd := model.Init() assert.Nil(t, cmd) }) @@ -32,9 +37,10 @@ func TestModel(t *testing.T) { name: "FileSelection to ModeSelection", model: Model{ State: "FileSelection", - FilesSelector: modelutils.InitialModel(".", 10), + FilesSelector: modelutils.InitialModel(tempDir, 10, 10), }, setup: func(m *Model) { + m.FilesSelector.MultipleSelection = true m.FilesSelector.FilesPath = []string{"path/test/file1", "path/test/file2"} }, @@ -51,16 +57,14 @@ func TestModel(t *testing.T) { name: "FileSelection to ActionSelection", model: Model{ State: "FileSelection", - FilesSelector: modelutils.InitialModel(".", 10), + FilesSelector: modelutils.InitialModel(tempDir, 10, 10), }, setup: func(m *Model) { - m.FilesSelector.FilesPath = []string{"path/test/file1"} - }, - msg: tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'x'}}, + msg: tea.KeyMsg{Type: tea.KeyEnter}, verify: func(t *testing.T, m Model) { assert.True(t, m.FilesSelector.Done) - assert.Contains(t, m.Files, "path/test/file1") + assert.Contains(t, m.Files, tempFile) assert.Equal(t, "ActionSelection", m.State) }, @@ -69,7 +73,7 @@ func TestModel(t *testing.T) { name: "No file selected", model: Model{ State: "FileSelection", - FilesSelector: modelutils.InitialModel(".", 10), + FilesSelector: modelutils.InitialModel(tempDir, 10, 10), }, msg: tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'x'}}, verify: func(t *testing.T, m Model) { @@ -82,7 +86,7 @@ func TestModel(t *testing.T) { name: "ModeSelection to ActionSelection", model: Model{ State: "ModeSelection", - SpeedSelector: modelutils.NewModeSelector([]string{"Fast mode", "Slow mode"}, "", ""), + SpeedSelector: modelutils.NewModeSelector([]string{"Fast mode", "Slow mode"}, "", "", 10, 10), Files: []string{"file1.txt", "file2.txt"}, }, msg: tea.KeyMsg{Type: tea.KeyEnter}, @@ -97,7 +101,7 @@ func TestModel(t *testing.T) { model: Model{ State: "ActionSelection", SpeedSelector: modelutils.ModeSelector{Selected: "Slow mode"}, - ActionSelector: modelutils.NewModeSelector([]string{"toggle", "comment", "uncomment"}, "", "Slow mode"), + ActionSelector: modelutils.NewModeSelector([]string{"toggle", "comment", "uncomment"}, "", "Slow mode", 10, 10), Files: []string{"file1.txt", "file2.txt"}, }, msg: tea.KeyMsg{Type: tea.KeyEnter}, @@ -111,7 +115,7 @@ func TestModel(t *testing.T) { model: Model{ State: "ActionSelection", SpeedSelector: modelutils.ModeSelector{Selected: "Fast mode"}, - ActionSelector: modelutils.NewModeSelector([]string{"toggle", "comment", "uncomment"}, "", "Fast mode"), + ActionSelector: modelutils.NewModeSelector([]string{"toggle", "comment", "uncomment"}, "", "Fast mode", 10, 10), Files: []string{"file1.txt"}, }, msg: tea.KeyMsg{Type: tea.KeyEnter}, @@ -125,7 +129,7 @@ func TestModel(t *testing.T) { model: Model{ State: "ActionSelection", SpeedSelector: modelutils.ModeSelector{Selected: "Slow mode"}, - ActionSelector: modelutils.NewModeSelector([]string{"toggle", "comment", "uncomment"}, "", "Slow mode"), + ActionSelector: modelutils.NewModeSelector([]string{"toggle", "comment", "uncomment"}, "", "Slow mode", 10, 10), Files: []string{"file1.txt", "file2.txt"}, Actions: []string{"test"}, }, @@ -156,8 +160,8 @@ func TestModel(t *testing.T) { name: "ModeSelection to FileSelection", model: Model{ State: "ModeSelection", - FilesSelector: modelutils.InitialModel(".", 10), - SpeedSelector: modelutils.NewModeSelector([]string{"Fast mode", "Slow mode"}, "", ""), + FilesSelector: modelutils.InitialModel(tempDir, 10, 10), + SpeedSelector: modelutils.NewModeSelector([]string{"Fast mode", "Slow mode"}, "", "", 10, 10), Files: []string{"file1.txt", "file2.txt"}, }, msg: tea.KeyMsg{Type: tea.KeyEsc}, @@ -174,9 +178,9 @@ func TestModel(t *testing.T) { name: "ActionSelection to FileSelection", model: Model{ State: "ActionSelection", - FilesSelector: modelutils.InitialModel(".", 10), + FilesSelector: modelutils.InitialModel(tempDir, 10, 10), SpeedSelector: modelutils.ModeSelector{Selected: "Fast mode"}, - ActionSelector: modelutils.NewModeSelector([]string{"toggle", "comment", "uncomment"}, "", ""), + ActionSelector: modelutils.NewModeSelector([]string{"toggle", "comment", "uncomment"}, "", "", 10, 10), Files: []string{"file1.txt"}, }, msg: tea.KeyMsg{Type: tea.KeyEsc}, @@ -192,7 +196,7 @@ func TestModel(t *testing.T) { model: Model{ State: "ActionSelection", SpeedSelector: modelutils.ModeSelector{Selected: "Fast mode"}, - ActionSelector: modelutils.NewModeSelector([]string{"toggle", "comment", "uncomment"}, "", ""), + ActionSelector: modelutils.NewModeSelector([]string{"toggle", "comment", "uncomment"}, "", "", 10, 10), Files: []string{"file1.txt", "file2.txt"}, }, msg: tea.KeyMsg{Type: tea.KeyEsc}, @@ -208,7 +212,7 @@ func TestModel(t *testing.T) { model: Model{ State: "ActionSelection", SpeedSelector: modelutils.ModeSelector{Selected: "Slow mode"}, - ActionSelector: modelutils.NewModeSelector([]string{"toggle", "comment", "uncomment"}, "", ""), + ActionSelector: modelutils.NewModeSelector([]string{"toggle", "comment", "uncomment"}, "", "", 10, 10), Files: []string{"file1.txt", "file2.txt"}, }, msg: tea.KeyMsg{Type: tea.KeyEsc}, @@ -223,7 +227,7 @@ func TestModel(t *testing.T) { model: Model{ State: "ActionSelection", SpeedSelector: modelutils.ModeSelector{Selected: "Slow mode"}, - ActionSelector: modelutils.NewModeSelector([]string{"toggle", "comment", "uncomment"}, "", "Slow mode"), + ActionSelector: modelutils.NewModeSelector([]string{"toggle", "comment", "uncomment"}, "", "Slow mode", 10, 10), Files: []string{"file1.txt", "file2.txt"}, Actions: []string{"test", "comment"}, }, @@ -240,7 +244,7 @@ func TestModel(t *testing.T) { model: Model{ State: "LabelInput", SpeedSelector: modelutils.ModeSelector{Selected: "Fast mode"}, - ActionSelector: modelutils.NewModeSelector([]string{"toggle", "comment", "uncomment"}, "", "Fast mode"), + ActionSelector: modelutils.NewModeSelector([]string{"toggle", "comment", "uncomment"}, "", "Fast mode", 10, 10), LabelInput: modelutils.LabelInput{Input: "1-3"}, Files: []string{"file1.txt"}, }, @@ -255,7 +259,7 @@ func TestModel(t *testing.T) { model: Model{ State: "LabelInput", SpeedSelector: modelutils.ModeSelector{Selected: "Slow mode"}, - ActionSelector: modelutils.NewModeSelector([]string{"toggle", "comment", "uncomment"}, "", "Slow mode"), + ActionSelector: modelutils.NewModeSelector([]string{"toggle", "comment", "uncomment"}, "", "Slow mode", 10, 10), LabelInput: modelutils.LabelInput{Input: "1-3"}, Files: []string{"file1.txt", "file2.txt"}, Actions: []string{"test", "comment"}, @@ -424,25 +428,9 @@ func TestModel(t *testing.T) { tests := []viewTest{ { name: "FileSelection View", - model: Model{State: "FileSelection", FilesSelector: modelutils.InitialModel(".", 10)}, + model: Model{State: "FileSelection", FilesSelector: modelutils.InitialModel(tempDir, 10, 10)}, expected: "Select the files you want to modify", }, - { - name: "ModeSelection View", - model: Model{State: "ModeSelection", SpeedSelector: modelutils.NewModeSelector([]string{"Fast mode", "Slow mode"}, "", "")}, - expected: "Select 'Fast mode'", - }, - { - name: "ActionSelection View", - model: Model{State: "ActionSelection", ActionSelector: modelutils.NewModeSelector([]string{"toggle", "comment", "uncomment"}, "", "Fast mode")}, - expected: "Select action", - }, - { - name: "LabelInput View", - model: Model{State: "LabelInput", LabelInput: modelutils.NewLabelInput("")}, - expected: "Type below the section to modify", - }, - { name: "Final View with Error", model: Model{State: "Final", Error: fmt.Errorf("test error")}, diff --git a/utils/tui/modelutils/file.go b/utils/tui/modelutils/file.go index 54ad8b3..21b755c 100644 --- a/utils/tui/modelutils/file.go +++ b/utils/tui/modelutils/file.go @@ -3,6 +3,7 @@ package modelutils import ( "fmt" "os" + "strings" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" @@ -19,9 +20,11 @@ type FilesSelector struct { WindowHeight int Error error NoFileSelected bool + WindowWidth int + MultipleSelection bool } -func InitialModel(currentDir string, windowHeight int) FilesSelector { +func InitialModel(currentDir string, windowHeight int, windowWidth int) FilesSelector { var filesAndDir []string selectedFilesAndDir := make(map[int]bool) @@ -47,6 +50,7 @@ func InitialModel(currentDir string, windowHeight int) FilesSelector { FilesAndDir: filesAndDir, SelectedFilesAndDir: selectedFilesAndDir, WindowHeight: windowHeight, + WindowWidth: windowWidth, } } @@ -63,6 +67,14 @@ func (m FilesSelector) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg.String() { case "ctrl+c", "q": return m, tea.Quit + case " ": + if !m.MultipleSelection { + m.MultipleSelection = true + } else { + m.MultipleSelection = false + m.FilesPath = []string{} + } + case "up": if m.cursor > 0 { m.cursor-- @@ -95,6 +107,9 @@ func (m FilesSelector) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.FilesPath = Remove(m.FilesPath, m.FilesAndDir[m.cursor]) } else { m.FilesPath = append(m.FilesPath, m.FilesAndDir[m.cursor]) + if !m.MultipleSelection { + m.Done = true + } } m.SelectedFilesAndDir[m.cursor] = !m.SelectedFilesAndDir[m.cursor] } @@ -105,30 +120,50 @@ func (m FilesSelector) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, tea.Quit } case "x": - if len(m.FilesPath) == 0 { - m.NoFileSelected = true - } else { - m.Done = true + if m.MultipleSelection { + if len(m.FilesPath) == 0 { + m.NoFileSelected = true + } else { + m.Done = true + } } } + case tea.WindowSizeMsg: + return m, m.doResize(msg) } return m, nil } +func (m *FilesSelector) doResize(msg tea.WindowSizeMsg) tea.Cmd { + m.WindowHeight = msg.Height + m.WindowWidth = msg.Width + return nil +} + func (m FilesSelector) View() string { if m.Error != nil { return Paint("red").Render(fmt.Sprintf("An error occurred: %v", m.Error)) } - s := Paint("silver").Render("\n Select the files you want to modify...") + "\n" - s += Paint("silver").Render("\n Selected files till now:") + "\n" + // Help messages + helpMessages := []string{ + "'q' to quit 'esc' to move to parent directory", + "'↑' to go up 'space' to select multiple files", + "'↓' to go down 'enter' to select pointed file/move to pointed sub folder", + "'x' to modify select files", + } + + // File selection and error messages + var sb strings.Builder + sb.WriteString(Paint("silver").Render("\n Select the files you want to modify...") + "\n") + sb.WriteString(Paint("silver").Render("\n Selected files till now:") + "\n") if m.NoFileSelected { - s += Paint("red").Render("\n No file selected. Please select at least one file or quit.") + "\n" + sb.WriteString(Paint("red").Render("\n No file selected. Please select at least one file or quit.") + "\n") } for i := 0; i < len(m.FilesPath); i++ { - s += fmt.Sprintf(" %s\n", Paint("green").Render(m.FilesPath[i])) + sb.WriteString(fmt.Sprintf(" %s\n", Paint("green").Render(m.FilesPath[i]))) } - s += "\n" + sb.WriteString("\n") for i := m.scrollOffset; i < m.scrollOffset+m.WindowHeight && i < len(m.FilesAndDir); i++ { choice := m.FilesAndDir[i] @@ -150,10 +185,19 @@ func (m FilesSelector) View() string { cursor = Paint("red").Render(" ➪") } - s += fmt.Sprintf("%s %s\n", cursor, choice) + sb.WriteString(fmt.Sprintf("%s %s\n", cursor, choice)) } - s += Paint("silver").Render("\n 'q' to quit 'esc' to move to parent directory\n '↑' to go up 'x' to modify selected files\n '↓' to go down 'enter' to select pointed file/move to pointed sub folder") - return s + + fileSelection := sb.String() + + // Combine file selection with help messages + helpView := lipgloss.JoinVertical(lipgloss.Left, helpMessages...) + content := lipgloss.JoinVertical(lipgloss.Left, fileSelection, helpView) + + // Place the content in the center of the screen + fullView := lipgloss.Place(m.WindowWidth, m.WindowHeight, lipgloss.Left, lipgloss.Left, content) + + return fullView } func Paint(color string) lipgloss.Style { diff --git a/utils/tui/modelutils/file_test.go b/utils/tui/modelutils/file_test.go index adff2f9..d7638db 100644 --- a/utils/tui/modelutils/file_test.go +++ b/utils/tui/modelutils/file_test.go @@ -30,14 +30,14 @@ func TestFilesSelector(t *testing.T) { { name: "InitialModel", setup: func(m *FilesSelector) { - *m = InitialModel(tempDir, 10) + *m = InitialModel(tempDir, 10, 10) }, verify: func(t *testing.T, m FilesSelector) { assert.Equal(t, tempDir, m.CurrentDir) assert.Contains(t, m.FilesAndDir, subDir) assert.NotNil(t, m.SelectedFilesAndDir) assert.Equal(t, 0, m.cursor) - assert.Equal(t, 10, m.WindowHeight) + assert.Equal(t, 10, 10, m.WindowHeight) assert.NoError(t, m.Error) }, }, @@ -45,7 +45,7 @@ func TestFilesSelector(t *testing.T) { name: "KeyDown", msg: tea.KeyMsg{Type: tea.KeyDown}, setup: func(m *FilesSelector) { - *m = InitialModel(tempDir, 10) + *m = InitialModel(tempDir, 10, 10) }, verify: func(t *testing.T, m FilesSelector) { @@ -56,7 +56,7 @@ func TestFilesSelector(t *testing.T) { name: "KeyUp", msg: tea.KeyMsg{Type: tea.KeyUp}, setup: func(m *FilesSelector) { - *m = InitialModel(tempDir, 10) + *m = InitialModel(tempDir, 10, 10) m.cursor = 1 }, verify: func(t *testing.T, m FilesSelector) { @@ -67,7 +67,7 @@ func TestFilesSelector(t *testing.T) { name: "EnterDirectory", msg: tea.KeyMsg{Type: tea.KeyEnter}, setup: func(m *FilesSelector) { - *m = InitialModel(tempDir, 10) + *m = InitialModel(tempDir, 10, 10) m.cursor = 1 }, verify: func(t *testing.T, m FilesSelector) { @@ -78,18 +78,19 @@ func TestFilesSelector(t *testing.T) { name: "SelectFile", msg: tea.KeyMsg{Type: tea.KeyEnter}, setup: func(m *FilesSelector) { - *m = InitialModel(subDir, 10) + *m = InitialModel(subDir, 10, 10) m.cursor = 0 }, verify: func(t *testing.T, m FilesSelector) { assert.Contains(t, m.FilesPath, tempFile) + assert.True(t, m.Done) }, }, { name: "Exit", msg: tea.KeyMsg{Type: tea.KeyCtrlC}, setup: func(m *FilesSelector) { - *m = InitialModel(tempDir, 10) + *m = InitialModel(tempDir, 10, 10) }, verify: func(t *testing.T, m FilesSelector) { // Call Update with the exit message @@ -105,17 +106,18 @@ func TestFilesSelector(t *testing.T) { name: "MoveToPreviousDir", msg: tea.KeyMsg{Type: tea.KeyEsc}, setup: func(m *FilesSelector) { - *m = InitialModel(subDir, 10) + *m = InitialModel(subDir, 10, 10) }, verify: func(t *testing.T, m FilesSelector) { assert.Equal(t, tempDir, m.CurrentDir) }, }, { - name: "Confirm", + name: "Confirm multiple file", msg: tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'x'}}, setup: func(m *FilesSelector) { - *m = InitialModel(tempDir, 10) + *m = InitialModel(tempDir, 10, 10) + m.MultipleSelection = true m.FilesPath = []string{tempFile} }, verify: func(t *testing.T, m FilesSelector) { @@ -127,7 +129,7 @@ func TestFilesSelector(t *testing.T) { name: "No file selected", msg: tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'x'}}, setup: func(m *FilesSelector) { - *m = InitialModel(tempDir, 10) + *m = InitialModel(tempDir, 10, 10) }, verify: func(t *testing.T, m FilesSelector) { assert.Equal(t, tempDir, m.CurrentDir) @@ -138,7 +140,7 @@ func TestFilesSelector(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - model := InitialModel(tempDir, 10) + model := InitialModel(tempDir, 10, 10) if tt.setup != nil { tt.setup(&model) } @@ -173,7 +175,7 @@ func TestFilesSelectorView(t *testing.T) { { name: "No selected file", setup: func(m *FilesSelector) { - *m = InitialModel(tempDir, 10) + *m = InitialModel(tempDir, 10, 10) }, verify: func(t *testing.T, view string) { assert.Contains(t, view, "Select the files you want to modify...") @@ -184,7 +186,7 @@ func TestFilesSelectorView(t *testing.T) { { name: " with a selected file", setup: func(m *FilesSelector) { - *m = InitialModel(tempDir, 10) + *m = InitialModel(tempDir, 10, 10) m.cursor = 1 m.FilesPath = append(m.FilesPath, tempFile1) }, @@ -198,7 +200,7 @@ func TestFilesSelectorView(t *testing.T) { { name: " inside subdir", setup: func(m *FilesSelector) { - *m = InitialModel(tempDir, 10) + *m = InitialModel(tempDir, 10, 10) msg := tea.KeyMsg{Type: tea.KeyEnter} m.cursor = 1 newModel, _ := m.Update(msg) @@ -212,7 +214,7 @@ func TestFilesSelectorView(t *testing.T) { { name: "Navigate above root directory", setup: func(m *FilesSelector) { - *m = InitialModel("/", 10) + *m = InitialModel("/", 10, 10) msg := tea.KeyMsg{Type: tea.KeyEsc} m.cursor = 1 newModel, _ := m.Update(msg) @@ -225,7 +227,7 @@ func TestFilesSelectorView(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - model := InitialModel(tempDir, 10) + model := InitialModel(tempDir, 10, 10) if tt.setup != nil { tt.setup(&model) } diff --git a/utils/tui/modelutils/option.go b/utils/tui/modelutils/option.go index 8f2e2e7..5b56e11 100644 --- a/utils/tui/modelutils/option.go +++ b/utils/tui/modelutils/option.go @@ -1,6 +1,9 @@ package modelutils -import tea "github.com/charmbracelet/bubbletea" +import ( + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) type ModeSelector struct { File string @@ -10,14 +13,18 @@ type ModeSelector struct { Done bool Speed string Back bool + Width int + Height int } -func NewModeSelector(choices []string, file string, speed string) ModeSelector { +func NewModeSelector(choices []string, file string, speed string, width, height int) ModeSelector { return ModeSelector{ File: file, Choices: choices, Selected: "", Speed: speed, + Height: height, + Width: width / 2, } } @@ -48,13 +55,16 @@ func (m ModeSelector) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.Selected = m.Choices[m.cursor] m.Done = true } + case tea.WindowSizeMsg: + m.Width = msg.Width / 2 + m.Height = msg.Height } return m, nil } func (m ModeSelector) View() string { if len(m.Choices) == 2 { - s := Paint("silver").Render("Select 'Fast mode' if you want to toggle all your files by giving just indications about start label and end label.\nSelect 'Slow mode' if you want to specify what action to perform file by file.") + "\n" + s := Paint("silver").Render("\n Select 'Fast mode' if you want to toggle all your files by giving just indications about start label and end label.\nSelect 'Slow mode' if you want to specify what action to perform file by file.") + "\n" for i, choice := range m.Choices { cursor := " " if m.cursor == i { @@ -62,13 +72,13 @@ func (m ModeSelector) View() string { } s += cursor + " " + choice + "\n" } - return s + return lipgloss.Place(m.Width, m.Height, lipgloss.Left, lipgloss.Center, s) } else { s := "" switch m.Speed { case "Slow mode": - s += Paint("silver").Render("Select action for file: "+m.File) + "\n\n" + s += Paint("silver").Render("\n Select action for file: "+m.File) + "\n\n" for i, choice := range m.Choices { cursor := " " if m.cursor == i { @@ -78,7 +88,7 @@ func (m ModeSelector) View() string { } case "Fast mode": - s += Paint("silver").Render("Select action:") + "\n\n" + s += Paint("silver").Render("\n Select action:") + "\n\n" for i, choice := range m.Choices { cursor := " " if m.cursor == i { @@ -87,7 +97,8 @@ func (m ModeSelector) View() string { s += cursor + " " + choice + "\n" } } - return s + Paint("silver").Render("\n 'q' to quit 'enter' to modify selected files 'esc' to go back\n '↑' to go up\n '↓' to go down") + s = s + Paint("silver").Render("\n 'q' to quit 'enter' to modify selected files 'esc' to go back\n '↑' to go up\n '↓' to go down") + return lipgloss.Place(m.Width, m.Height, lipgloss.Left, lipgloss.Top, s) } } diff --git a/utils/tui/modelutils/option_test.go b/utils/tui/modelutils/option_test.go index dac2a7d..fb5d678 100644 --- a/utils/tui/modelutils/option_test.go +++ b/utils/tui/modelutils/option_test.go @@ -27,13 +27,15 @@ func TestNewModeSelector(t *testing.T) { Speed: "", Done: false, Back: false, + Width: 5, + Height: 10, }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - selector := NewModeSelector(tt.choices, tt.file, tt.speed) + selector := NewModeSelector(tt.choices, tt.file, tt.speed, 10, 10) assert.Equal(t, tt.expected.File, selector.File) assert.Equal(t, tt.expected.Choices, selector.Choices) assert.Equal(t, tt.expected.Selected, selector.Selected) @@ -45,7 +47,7 @@ func TestNewModeSelector(t *testing.T) { } func TestInit(t *testing.T) { - selector := NewModeSelector([]string{"Option1", "Option2"}, "", "") + selector := NewModeSelector([]string{"Option1", "Option2"}, "", "", 10, 10) cmd := selector.Init() assert.Nil(t, cmd) } @@ -60,50 +62,60 @@ func TestUpdate(t *testing.T) { }{ { name: "Test up key", - initial: NewModeSelector([]string{"Option1", "Option2"}, "", ""), + initial: NewModeSelector([]string{"Option1", "Option2"}, "", "", 10, 10), msg: tea.KeyMsg{Type: tea.KeyUp}, expected: ModeSelector{ Choices: []string{"Option1", "Option2"}, cursor: 0, + Width: 5, + Height: 10, }, }, { name: "Test down key", - initial: NewModeSelector([]string{"Option1", "Option2"}, "", ""), + initial: NewModeSelector([]string{"Option1", "Option2"}, "", "", 10, 10), msg: tea.KeyMsg{Type: tea.KeyDown}, expected: ModeSelector{ Choices: []string{"Option1", "Option2"}, cursor: 1, + Width: 5, + Height: 10, }, }, { name: "Test enter key", - initial: NewModeSelector([]string{"Option1", "Option2"}, "", ""), + initial: NewModeSelector([]string{"Option1", "Option2"}, "", "", 10, 10), msg: tea.KeyMsg{Type: tea.KeyEnter}, expected: ModeSelector{ Choices: []string{"Option1", "Option2"}, cursor: 0, Selected: "Option1", Done: true, + Width: 5, + Height: 10, }, }, { name: "Test esc key", - initial: NewModeSelector([]string{"Option1", "Option2"}, "", ""), + initial: NewModeSelector([]string{"Option1", "Option2"}, "", "", 10, 10), msg: tea.KeyMsg{Type: tea.KeyEsc}, expected: ModeSelector{ Choices: []string{"Option1", "Option2"}, cursor: 0, Back: true, + Width: 5, + Height: 10, }, }, { name: "Test quit keys", - initial: NewModeSelector([]string{"Option1", "Option2"}, "", ""), + initial: NewModeSelector([]string{"Option1", "Option2"}, "", "", 10, 10), msg: tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'q'}, Alt: false}, expected: ModeSelector{ Choices: []string{"Option1", "Option2"}, cursor: 0, + Width: 5, + Height: 10, }, cmdChecker: func(cmd tea.Cmd) { if cmd != nil { @@ -136,7 +148,7 @@ func TestView(t *testing.T) { }{ { name: "View with cursor at default position", - selector: NewModeSelector([]string{"Option1", "Option2"}, "testfile", "Fast mode"), + selector: NewModeSelector([]string{"Option1", "Option2"}, "testfile", "Fast mode", 10, 10), expected: "Select 'Fast mode' if you want to toggle all your files by giving just indications about start label and end label. Select 'Slow mode' if you want to specify what action to perform file by file. > Option1 Option2", }, { diff --git a/utils/tui/modelutils/text.go b/utils/tui/modelutils/text.go index afb9009..a3a1a24 100644 --- a/utils/tui/modelutils/text.go +++ b/utils/tui/modelutils/text.go @@ -7,6 +7,7 @@ import ( "time" tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" ) type LabelInput struct { @@ -17,14 +18,18 @@ type LabelInput struct { flash bool Error error Back bool + Width int + Height int } -func NewLabelInput(File string) LabelInput { +func NewLabelInput(File string, width, height int) LabelInput { return LabelInput{ File: File, Input: "", Done: false, IsLabel: false, + Height: height, + Width: width / 2, } } @@ -56,6 +61,9 @@ func (m LabelInput) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tea.KeyEsc: m.Back = true } + case tea.WindowSizeMsg: + m.Width = msg.Width / 2 + m.Height = msg.Height case tickMsg: m.flash = !m.flash @@ -72,7 +80,7 @@ func (m LabelInput) View() string { flash = Paint("green").Render("▎") } - s := Paint("silver").Render("Type below the section to modify. You can insert your start label\nand your end label using the syntax 'start';'end' or you can modify\n a single line by entering the line number or a range of lines using the syntax x-y") + "\n\n" + s := Paint("silver").Render("\n Type below the section to modify. You can insert your start label\nand your end label using the syntax 'start';'end' or you can modify\n a single line by entering the line number or a range of lines using the syntax x-y") + "\n\n" if m.File != "" { s += Paint("green").Render(m.File+": ✏ "+m.Input) + flash + "\n" } else { @@ -84,7 +92,7 @@ func (m LabelInput) View() string { } s += Paint("silver").Render("\n 'ctrl +c' to quit 'enter' to select the lines/labels indicated 'esc' to go back\n '↑' to go up\n '↓' to go down") - return s + return lipgloss.Place(m.Width, m.Height, lipgloss.Left, lipgloss.Top, s) } func StartTicker() tea.Cmd { diff --git a/utils/tui/modelutils/text_test.go b/utils/tui/modelutils/text_test.go index edeb150..23c60ad 100644 --- a/utils/tui/modelutils/text_test.go +++ b/utils/tui/modelutils/text_test.go @@ -42,20 +42,22 @@ func TestNewLabelInput(t *testing.T) { Input: "", Done: false, IsLabel: false, + Width: 5, + Height: 10, }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - input := NewLabelInput(tt.file) + input := NewLabelInput(tt.file, 10, 10) assert.Equal(t, tt.expected, input) }) } } func TestInitLabelInput(t *testing.T) { - input := NewLabelInput("testfile") + input := NewLabelInput("testfile", 10, 10) cmd := input.Init() assert.NotNil(t, cmd) } @@ -69,29 +71,33 @@ func TestUpdateLabelInput(t *testing.T) { }{ { name: "Test KeyEnter with valid input label", - initial: setInput(NewLabelInput(""), "test;test"), + initial: setInput(NewLabelInput("", 10, 10), "test;test"), msg: tea.KeyMsg{Type: tea.KeyEnter}, expected: LabelInput{ File: "", Input: "test;test", Done: true, IsLabel: true, + Width: 5, + Height: 10, }, }, { name: "Test KeyEnter with valid input lines", - initial: setInput(NewLabelInput(""), "1"), + initial: setInput(NewLabelInput("", 10, 10), "1"), msg: tea.KeyMsg{Type: tea.KeyEnter}, expected: LabelInput{ File: "", Input: "1", Done: true, IsLabel: false, + Width: 5, + Height: 10, }, }, { name: "Test KeyEnter with invalid input", - initial: setInput(NewLabelInput(""), ""), + initial: setInput(NewLabelInput("", 10, 10), ""), msg: tea.KeyMsg{Type: tea.KeyEnter}, expected: LabelInput{ File: "", @@ -99,33 +105,39 @@ func TestUpdateLabelInput(t *testing.T) { Done: false, IsLabel: false, Error: fmt.Errorf("input does not match expected format (e.g., 'start';'end' or 'x-y' or single line number)"), + Width: 5, + Height: 10, }, }, { name: "Test KeyBackspace", - initial: NewLabelInput(""), + initial: NewLabelInput("", 10, 10), msg: tea.KeyMsg{Type: tea.KeyBackspace}, expected: LabelInput{ File: "", Input: "", Done: false, IsLabel: false, + Width: 5, + Height: 10, }, }, { name: "Test KeyRunes", - initial: NewLabelInput(""), + initial: NewLabelInput("", 10, 10), msg: tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'t', 'e', 's', 't'}}, expected: LabelInput{ File: "", Input: "test", Done: false, IsLabel: false, + Width: 5, + Height: 10, }, }, { name: "Test KeyEsc", - initial: NewLabelInput(""), + initial: NewLabelInput("", 10, 10), msg: tea.KeyMsg{Type: tea.KeyEsc}, expected: LabelInput{ File: "", @@ -133,6 +145,8 @@ func TestUpdateLabelInput(t *testing.T) { Done: false, IsLabel: false, Back: true, + Width: 5, + Height: 10, }, }, }