From c7ff7662baa2e6d72e313fab89711864400cacb5 Mon Sep 17 00:00:00 2001 From: XeRo Date: Mon, 19 Jan 2026 17:44:49 +0500 Subject: [PATCH] =?UTF-8?q?=D0=A0=D0=B0=D0=B1=D0=BE=D1=82=D0=B0=20=D0=BD?= =?UTF-8?q?=D0=B0=D0=B4=20viewport=20=D1=84=D0=BE=D1=80=D0=BC=D1=8B,=20?= =?UTF-8?q?=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=D0=B8=D0=B5=20?= =?UTF-8?q?=D0=BE=D0=BF=D0=B8=D1=81=D0=B0=D0=BD=D0=B8=D1=8F=20=D0=B4=D0=BB?= =?UTF-8?q?=D1=8F=20=D0=BF=D0=BE=D0=BB=D1=8F=20=D1=84=D0=BE=D1=80=D0=BC?= =?UTF-8?q?=D1=8B,=20=D0=BA=D0=BE=D0=BC=D0=BC=D0=B5=D0=BD=D1=82=D0=B0?= =?UTF-8?q?=D1=80=D0=B8=D0=B8=20=D0=BA=20fileSelect=20=D0=BC=D0=BE=D0=B4?= =?UTF-8?q?=D0=B5=D0=BB=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- setup/fields/checkbox.go | 16 +- setup/fields/compute.go | 18 +- setup/fields/dirSelect.go | 9 +- setup/fields/fieldModel.go | 18 +- setup/fields/fileSelect.go | 54 ++++-- setup/fields/input.go | 18 +- setup/fields/select.go | 25 ++- setup/final.go | 2 +- setup/form.go | 380 ++++++++++++++++++++++--------------- 9 files changed, 333 insertions(+), 207 deletions(-) diff --git a/setup/fields/checkbox.go b/setup/fields/checkbox.go index 74b7f6d..83b0cee 100644 --- a/setup/fields/checkbox.go +++ b/setup/fields/checkbox.go @@ -1,18 +1,21 @@ package fields import ( + "fmt" tea "github.com/charmbracelet/bubbletea" ) type CheckboxModel struct { - Label string - Checked bool + Label string + description string + Checked bool } -func NewCheckboxModel(label string, checked bool) *CheckboxModel { +func NewCheckboxModel(label, description string, checked bool) *CheckboxModel { return &CheckboxModel{ - Label: label, - Checked: checked, + Label: label, + description: description, + Checked: checked, } } @@ -39,6 +42,9 @@ func (m *CheckboxModel) View() string { } else { res += "[ ]" } + if m.description != "" { + res += fmt.Sprintf("\n%s", m.description) + } return res } diff --git a/setup/fields/compute.go b/setup/fields/compute.go index c1f281b..f671b73 100644 --- a/setup/fields/compute.go +++ b/setup/fields/compute.go @@ -6,14 +6,16 @@ import ( ) type ComputeModel struct { - Label string - value string + Label string + description string + value string } -func NewComputeModel(label, value string) *ComputeModel { +func NewComputeModel(label, value, description string) *ComputeModel { return &ComputeModel{ - Label: label, - value: value, + Label: label, + description: description, + value: value, } } @@ -26,7 +28,11 @@ func (m *ComputeModel) Update(msg tea.Msg) (FieldModel, tea.Cmd) { } func (m *ComputeModel) View() string { - return fmt.Sprintf("%s: %s", m.Label, m.value) + res := fmt.Sprintf("%s: %s", m.Label, m.value) + if m.description != "" { + res += fmt.Sprintf("\n%s", m.description) + } + return res } func (m *ComputeModel) Focus() {} diff --git a/setup/fields/dirSelect.go b/setup/fields/dirSelect.go index 7c1d786..9ec317b 100644 --- a/setup/fields/dirSelect.go +++ b/setup/fields/dirSelect.go @@ -14,6 +14,7 @@ import ( type DirSelectModel struct { input textinput.Model label string + description string focus int open bool dir string @@ -22,16 +23,17 @@ type DirSelectModel struct { optionsCount int } -func NewDirSelectModel(label, dir string) *DirSelectModel { +func NewDirSelectModel(label, dir, description string) *DirSelectModel { inputModel := textinput.New() inputModel.SetValue(dir) return &DirSelectModel{ input: inputModel, label: label, + description: description, currentDirectory: filepath.Dir(dir), open: false, dir: dir, - optionsCount: 3, + optionsCount: 10, } } @@ -124,6 +126,9 @@ func (m *DirSelectModel) Update(msg tea.Msg) (FieldModel, tea.Cmd) { func (m *DirSelectModel) View() string { res := fmt.Sprintf("%s: %s", m.label, m.input.View()) + if m.description != "" { + res += fmt.Sprintf("\n%s", m.description) + } if m.open && len(m.options) > 0 { visibleCount := m.optionsCount if visibleCount > len(m.options) { diff --git a/setup/fields/fieldModel.go b/setup/fields/fieldModel.go index 12e5463..1cb48e0 100644 --- a/setup/fields/fieldModel.go +++ b/setup/fields/fieldModel.go @@ -2,15 +2,13 @@ package fields import tea "github.com/charmbracelet/bubbletea" -// todo комментарии к полям -// todo описание поля выводить type FieldModel interface { - Init() tea.Cmd - Update(msg tea.Msg) (FieldModel, tea.Cmd) - View() string - Focus() - Blur() - CaptureKeys() bool - GetLabel() string - GetValue() interface{} + Init() tea.Cmd //Метод инициализации поля + Update(msg tea.Msg) (FieldModel, tea.Cmd) //Метод отслеживания событий связанных с моделью + View() string //Метод отрисовки модели + Focus() //Метод установки фокуса + Blur() //Метод удаления фокуса + CaptureKeys() bool //Метод проверяющий захват событий + GetLabel() string //Получить имя поля + GetValue() interface{} //Получить значение поля } diff --git a/setup/fields/fileSelect.go b/setup/fields/fileSelect.go index 2c421d0..7eadd2e 100644 --- a/setup/fields/fileSelect.go +++ b/setup/fields/fileSelect.go @@ -13,29 +13,33 @@ import ( "time" ) +// FileSelectModel структура описывающая модель поля выбора файла type FileSelectModel struct { - input textinput.Model - label string - focus int - open bool - file string - currentDirectory string - options []os.FileInfo - extensions []string - optionsCount int + input textinput.Model //Готовый компонент input для ввода данных + label string //Имя поля + description string //Описание поля + focus int //Фокус для управления опциями + open bool //Открыт ли список + file string //Имя выбранного файла + currentDirectory string //Директория текущего файла + options []os.FileInfo //Элементы выбора файла + extensions []string //Допустимые расширения файла + optionsCount int //Количество элементов к показу } -func NewFileSelectModel(label, file string, extensions []string) *FileSelectModel { - inputModel := textinput.NewModel() +// NewFileSelectModel создаем новую модель поля выбора файла +func NewFileSelectModel(label, file, description string, extensions []string) *FileSelectModel { + inputModel := textinput.New() inputModel.SetValue(file) return &FileSelectModel{ input: inputModel, label: label, + description: description, extensions: extensions, currentDirectory: filepath.Dir(file), open: false, file: file, - optionsCount: 3, + optionsCount: 10, } } @@ -43,26 +47,37 @@ func (m *FileSelectModel) Init() tea.Cmd { return textinput.Blink } +// Update отслеживает действия с полем func (m *FileSelectModel) Update(msg tea.Msg) (FieldModel, tea.Cmd) { var cmds []tea.Cmd switch msg := msg.(type) { case tea.KeyMsg: + //Отслеживает нажатия клавиш. + //Если список открыт, то отслеживаем клавиши отдельно if m.open { switch msg.String() { case "esc": + //Закрываем список m.open = false case "up": + //Уменьшаем фокус, поднимаем его. m.focus-- if m.focus < 0 { m.focus = len(m.options) - 1 } case "down": + //Увеличиваем фокус, опускаем его. m.focus++ if m.focus == len(m.options) { m.focus = 0 } case "enter": + //Выбираем файл из списка. Если список пуст ничего не делаем. + //Получаем имя файла. + //Если имя файла ".." возвращаемся в родительскую папку. + //Если файл является директорией, то переходим в эту директорию. + //Если у файла совпадают расширения, то записываем его и закрываем список. if len(m.options) == 0 || m.focus == len(m.options) { return m, nil } @@ -97,6 +112,8 @@ func (m *FileSelectModel) Update(msg tea.Msg) (FieldModel, tea.Cmd) { } } } else { + //Если список не открыт и был нажат enter открываем его и рисуем опции списка. + //Иначе просто передаем сообщение нажатия клавиш в input. switch msg.String() { case "enter": m.open = true @@ -113,6 +130,7 @@ func (m *FileSelectModel) Update(msg tea.Msg) (FieldModel, tea.Cmd) { } } default: + //Все прочие виды сообщений передаем в input если список закрыт if !m.open { var cmd tea.Cmd m.input, cmd = m.input.Update(msg) @@ -124,6 +142,9 @@ func (m *FileSelectModel) Update(msg tea.Msg) (FieldModel, tea.Cmd) { func (m *FileSelectModel) View() string { res := fmt.Sprintf("%s: %s", m.label, m.input.View()) + if m.description != "" { + res += fmt.Sprintf("\n%s", m.description) + } if m.open && len(m.options) > 0 { visibleCount := m.optionsCount //количество отображаемых if visibleCount > len(m.options) { //если количество отображаемых больше, чем есть опций на самом деле @@ -174,6 +195,7 @@ func (m *FileSelectModel) Blur() { m.input.Blur() } +// CaptureKeys возвращает открыт ли список опций func (m *FileSelectModel) CaptureKeys() bool { return m.open } @@ -186,7 +208,9 @@ func (m *FileSelectModel) GetValue() interface{} { return m.file } +// RenderOptions заполняем список опций на основе директорий func (m *FileSelectModel) RenderOptions() { + //Читаем файлы в текущей директории filesInDir, err := os.ReadDir(m.currentDirectory) if err != nil { logrus.Errorf("Ошибка чтения директории: %v", err) @@ -195,15 +219,16 @@ func (m *FileSelectModel) RenderOptions() { m.options = nil var allOptions []os.FileInfo + //Если это не корень файловой системы, добавляем переход на родительскую директорию if m.currentDirectory != "/" && filepath.Clean(m.currentDirectory) != "/" { allOptions = append(allOptions, virtualDirInfo{name: ".."}) - //m.options = append(m.options, virtualDirInfo{name: ".."}) } + //Заполняем прочие опции for _, entry := range filesInDir { info, _ := entry.Info() allOptions = append(allOptions, info) - //m.options = append(m.options, info) } + //Заполняем фильтр для опций на основе значения поля ввода filter := "" if m.open { inputValue := strings.TrimSpace(m.input.Value()) @@ -214,6 +239,7 @@ func (m *FileSelectModel) RenderOptions() { } } } + //Чистим опции и заполняем их правильными значениями m.options = m.options[:0] for _, opt := range allOptions { name := opt.Name() diff --git a/setup/fields/input.go b/setup/fields/input.go index 9bd95d2..a06b069 100644 --- a/setup/fields/input.go +++ b/setup/fields/input.go @@ -7,18 +7,22 @@ import ( tea "github.com/charmbracelet/bubbletea" ) +// InputModel структура описывающая модель поля ввода данных type InputModel struct { - label string - input textinput.Model + label string + description string + input textinput.Model } -func NewInputModel(label, value string) *InputModel { +// NewInputModel создает новую модель поля ввода данных +func NewInputModel(label, value, description string) *InputModel { t := textinput.New() t.SetValue(value) t.Cursor.SetMode(cursor.CursorBlink) return &InputModel{ - label: label, - input: t, + label: label, + description: description, + input: t, } } @@ -26,6 +30,7 @@ func (m *InputModel) Init() tea.Cmd { return textinput.Blink } +// Update отслеживает действия с полем func (m *InputModel) Update(msg tea.Msg) (FieldModel, tea.Cmd) { var cmd tea.Cmd m.input, cmd = m.input.Update(msg) @@ -34,6 +39,9 @@ func (m *InputModel) Update(msg tea.Msg) (FieldModel, tea.Cmd) { func (m *InputModel) View() string { res := fmt.Sprintf("%s: %v", m.label, m.input.View()) + if m.description != "" { + res += fmt.Sprintf("\n%s", m.description) + } return res } diff --git a/setup/fields/select.go b/setup/fields/select.go index 4506895..1883140 100644 --- a/setup/fields/select.go +++ b/setup/fields/select.go @@ -7,14 +7,15 @@ import ( ) type SelectModel struct { - label string - options []string - focus int - open bool - chosen string + label string + description string + options []string + focus int + open bool + chosen string } -func NewSelectModel(label, value string, options []string) *SelectModel { +func NewSelectModel(label, value, description string, options []string) *SelectModel { found := false for i := range options { if options[i] == value { @@ -25,10 +26,11 @@ func NewSelectModel(label, value string, options []string) *SelectModel { value = "" } return &SelectModel{ - options: options, - label: label, - focus: len(options), - chosen: value, + options: options, + label: label, + description: description, + focus: len(options), + chosen: value, } } @@ -85,6 +87,9 @@ func (m *SelectModel) Update(msg tea.Msg) (FieldModel, tea.Cmd) { func (m *SelectModel) View() string { res := fmt.Sprintf("%s: %s", m.label, m.chosen) + if m.description != "" { + res += fmt.Sprintf("\n%s", m.description) + } if m.open && len(m.options) > 0 { for i := range m.options { if m.focus == i { diff --git a/setup/final.go b/setup/final.go index e62d2fe..744642e 100644 --- a/setup/final.go +++ b/setup/final.go @@ -10,7 +10,7 @@ import ( "github.com/charmbracelet/lipgloss/table" ) -// FinalModel структур описывающая модель отрисовки таблицы переменных +// FinalModel структура описывающая модель отрисовки таблицы переменных type FinalModel struct { variables []types.Variable //Список переменных table *table.Table //Таблица для вывода diff --git a/setup/form.go b/setup/form.go index 74a2377..1d91d8d 100644 --- a/setup/form.go +++ b/setup/form.go @@ -15,13 +15,13 @@ import ( // FormModel структура описывающая модель отрисовки формы настройки type FormModel struct { - CurrentSection *types.Section //Указатель секции с которой надо работать - FieldModels []fields.FieldModel //Массив моделей полей - focus int //Фокус для отслеживания с каким объектом надо работать - renderedContent string //Строка хранения информации о приложении // todo поместить во viewport - viewport viewport.Model //Объект куда помещена информация о приложении для возможности прокручивать её - width int //Ширина окна - height int //Высота окна + CurrentSection *types.Section //Указатель секции с которой надо работать + FieldModels []fields.FieldModel //Массив моделей полей + focus int //Фокус для отслеживания с каким объектом надо работать + content string //Строка хранения информации о приложении + viewport viewport.Model //Объект куда помещена информация о приложении для возможности прокручивать её + width int //Ширина окна + height int //Высота окна } // NewFormModel создает новую модель формы на основе данных настраиваемого приложения @@ -47,7 +47,7 @@ func NewFormModel(application *types.PackageApplication, sectionIdx, focus, widt } } selected := fmt.Sprintf("%v", sectionField.Variable.Value) - fieldModels = append(fieldModels, fields.NewSelectModel(sectionField.Name, selected, options)) + fieldModels = append(fieldModels, fields.NewSelectModel(sectionField.Name, selected, sectionField.Description, options)) case "check": checked := false if value, ok := sectionField.Variable.Value.(bool); ok { @@ -55,20 +55,20 @@ func NewFormModel(application *types.PackageApplication, sectionIdx, focus, widt } else { checked = sectionField.Variable.Value == "true" } - fieldModels = append(fieldModels, fields.NewCheckboxModel(sectionField.Name, checked)) + fieldModels = append(fieldModels, fields.NewCheckboxModel(sectionField.Name, sectionField.Description, checked)) case "compute": value, err := ComputeValue(sectionField.Compute, application) if err != nil { value = fmt.Sprintf("Ошибка вычисления: %v", err) } - fieldModels = append(fieldModels, fields.NewComputeModel(sectionField.Name, fmt.Sprintf("%v", value))) + fieldModels = append(fieldModels, fields.NewComputeModel(sectionField.Name, fmt.Sprintf("%v", value), sectionField.Description)) //compute расчет case "file": - fieldModels = append(fieldModels, fields.NewFileSelectModel(sectionField.Name, fmt.Sprintf("%v", sectionField.Variable.Value), sectionField.FileFieldExtensions)) + fieldModels = append(fieldModels, fields.NewFileSelectModel(sectionField.Name, fmt.Sprintf("%v", sectionField.Variable.Value), sectionField.Description, sectionField.FileFieldExtensions)) case "directory": - fieldModels = append(fieldModels, fields.NewDirSelectModel(sectionField.Name, fmt.Sprintf("%v", sectionField.Variable.Value))) + fieldModels = append(fieldModels, fields.NewDirSelectModel(sectionField.Name, fmt.Sprintf("%v", sectionField.Variable.Value), sectionField.Description)) default: - fieldModels = append(fieldModels, fields.NewInputModel(sectionField.Name, fmt.Sprintf("%v", sectionField.Variable.Value))) + fieldModels = append(fieldModels, fields.NewInputModel(sectionField.Name, fmt.Sprintf("%v", sectionField.Variable.Value), sectionField.Description)) } } //Устанавливаем фокус (фокус может быть не равен нулю, если новая форма сформирована после Sugar события, который обновляет форму в ТЕКУЩЕМ состоянии) @@ -100,15 +100,13 @@ func (m *FormModel) Init() tea.Cmd { // Update отслеживает действия с формой func (m *FormModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + //todo сообщение для переноса viewport вверх или внизе switch msg := msg.(type) { case tea.WindowSizeMsg: //Отслеживаем и записываем новые размеры окна, обновляем рендер about m.width = msg.Width m.height = msg.Height m.initViewport(m.width, m.height) - //if m.CurrentSection.Type == types.SectionAbout { - // m.renderedContent = simpleAboutRender(m.CurrentSection.Paragraphs, m.width) - //} return m, nil case tea.KeyMsg: //Отслеживаем нажатие клавиш формы @@ -179,6 +177,8 @@ func (m *FormModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } else if key == "shift+tab" || key == "up" { m.focus-- } + } else { + return m, m.updateFields(msg) } } else { if key == "tab" || key == "down" { @@ -199,80 +199,118 @@ func (m *FormModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } } } - //Если не было возвращено сообщений, значит действие было произведено с полем, а не с самой формой. - //Возвращаем результат функции обновления полей. - var cmd tea.Cmd - if m.CurrentSection.Type == types.SectionAbout { - m.viewport, cmd = m.viewport.Update(msg) - return m, cmd - } else { - return m, m.updateFields(msg) - } + var viewportCmd, fieldsCmd tea.Cmd + m.viewport, viewportCmd = m.viewport.Update(msg) + fieldsCmd = m.updateFields(msg) + return m, tea.Batch(viewportCmd, fieldsCmd) } -// View формирует строку для вывода формы и её контента. func (m *FormModel) View() string { - containerWidth, containerHeight, contentWidth, contentHeight := ui.ContentSizes(m.width, m.height) + containerWidth, containerHeight, contentWidth, _ := ui.ContentSizes(m.width, m.height) + m.rebuildContent(contentWidth) - formWidth := contentWidth - 2*1 - //formHeight:=contentHeight-2*1 - _ = contentHeight - 2*1 - - m.rebuildContent(formWidth) container := ui.AppContainer. Width(containerWidth). Height(containerHeight). - Render(m.renderedContent) + Render(m.viewport.View()) + return lipgloss.Place( m.width, m.height, lipgloss.Center, lipgloss.Center, container, - lipgloss.WithWhitespaceChars("0"), - lipgloss.WithWhitespaceForeground(ui.ColorError), + lipgloss.WithWhitespaceChars(" "), ) +} - ////Данные о размерах контейнера и ширины контента внутри - //containerWidth, containerHeight, contentWidth, _ := ui.ContentSizes(m.width, m.height) - //formWidth := contentWidth - 2*1 - //var strokes []string //отображаемые строки - //for i, field := range m.FieldModels { - // //Строка с учетом переноса поля. - // //Имеется ввиду, что если вводимое значение слишком длинное, то оно будет делиться на несколько строк. - // fieldStroke := wordwrap.String(field.View(), formWidth) - // //Если фокус равен текущему полю, то выделяем его. - // if i == m.focus { - // strokes = append(strokes, ui.FormFieldFocused.Render(fieldStroke)) - // } else { - // strokes = append(strokes, ui.FormFieldUnfocused.Render(fieldStroke)) - // } - //} - ////Если текущая секция это секция About, то выводим её информацию. - ////Не предполагается, что информация о приложении и поля для настройки будут в одной секции, поэтому тут идет обнуление прошлых строк. - //if m.CurrentSection.Type == types.SectionAbout { - // strokes = append([]string{}, m.renderedContent) - //} - ////Строка кнопки переключения секций. - ////Если позиция фокуса равняется количеству полей, значит он сейчас находится на кнопке. - //nextBtn := "[ next section ]" - //if m.focus == len(m.FieldModels) { - // strokes = append(strokes, ui.FormNextButton.Render(nextBtn)) - //} else { - // strokes = append(strokes, ui.FormFieldUnfocused.Render(nextBtn)) - //} - // - ////Основной контейнер приложения. - ////Устанавливаем ему ширину и высоту, объединяем все строки в одну с соединением, через двойной перевод строки. - //container := ui.AppContainer. - // Width(containerWidth). - // Height(containerHeight). - // Render(strings.Join(strokes, "\n\n")) - //return lipgloss.Place( - // m.width, m.height, - // lipgloss.Center, lipgloss.Center, - // container, - // lipgloss.WithWhitespaceChars(" "), - //) +func (m *FormModel) initViewport(width, height int) { + _, _, contentWidth, contentHeight := ui.ContentSizes(width, height) + contentAreaWidth := contentWidth - 2*1 + contentAreaHeight := contentHeight - 2*1 + m.viewport = viewport.New(contentWidth, contentAreaHeight) + m.viewport.SetHorizontalStep(1) + m.rebuildContent(contentAreaWidth) +} + +func (m *FormModel) rebuildContent(width int) { + var content string + if m.CurrentSection.Type == types.SectionAbout { + content = simpleAboutRender(m.CurrentSection.Paragraphs, width) + } else { + content = simpleForm(m.FieldModels, m.focus, width) + } + nextBtn := "[ next section ]" + if m.focus == len(m.FieldModels) { + nextBtn = ui.FormNextButton.Render(nextBtn) + } else { + nextBtn = ui.FormFieldUnfocused.Render(nextBtn) + } + fullContent := lipgloss.JoinVertical(lipgloss.Left, content, nextBtn) + m.content = fullContent + m.viewport.SetContent(m.content) +} + +func simpleForm(fields []fields.FieldModel, focus, width int) string { + var sb strings.Builder + for i, field := range fields { + //Строка с учетом переноса поля. + //Имеется ввиду, что если вводимое значение слишком длинное, то оно будет делиться на несколько строк. + fieldStroke := wordwrap.String(field.View(), width) + //Если фокус равен текущему полю, то выделяем его. + if i == focus { + sb.WriteString(ui.FormFieldFocused.Render(fieldStroke) + "\n\n") + } else { + sb.WriteString(ui.FormFieldUnfocused.Render(fieldStroke) + "\n\n") + } + } + return sb.String() +} + +// simpleAboutRender формирует единую строку, описывающую настраиваемое приложение. +func simpleAboutRender(paragraphs []string, width int) string { + var sb strings.Builder + for _, p := range paragraphs { + p = strings.TrimSpace(p) + if p == "" { + sb.WriteString("\n") + continue + } + switch { + case strings.HasPrefix(p, "# "): + // Заголовок 1 уровня + //h1Len := utf8.RuneCountInString(p[2:]) + //h1Offset := strings.Repeat(" ", (width-h1Len)/2) + //sb.WriteString(ui.AboutTitle.Render(h1Offset+p[2:]) + "\n\n") + text := strings.TrimPrefix(p, "# ") + wrapped := wordwrap.String(text, width) + sb.WriteString(ui.AboutTitle.Render(wrapped) + "\n\n") + case strings.HasPrefix(p, "## "): + // Заголовок 2 уровня + text := strings.TrimPrefix(p, "## ") + wrapped := wordwrap.String(text, width) + sb.WriteString(ui.AboutH2.Render(wrapped) + "\n\n") + case strings.HasPrefix(p, "### "): + // Заголовок 3 уровня + text := strings.TrimPrefix(p, "### ") + wrapped := wordwrap.String(text, width) + sb.WriteString(ui.AboutH3.Render(wrapped) + "\n\n") + case strings.HasPrefix(p, "- "), strings.HasPrefix(p, "* "): + // Список простой + text := strings.TrimLeft(p[2:], " ") + wrapped := wordwrap.String(text, width) + sb.WriteString(" " + ui.AboutBullet.Render("• ") + ui.AboutNormal.Render(wrapped) + "\n") + case strings.HasPrefix(p, "> "): + // Цитаты + text := strings.TrimPrefix(p, "> ") + wrapped := wordwrap.String(text, width) + sb.WriteString(" " + ui.AboutNormal.Foreground(ui.ColorGrey).Render("│ "+wrapped) + "\n") + default: + // Обычный текст + wrapped := wordwrap.String(p, width) + sb.WriteString(" " + ui.AboutNormal.Render(wrapped) + "\n\n") + } + } + return sb.String() } // updateFields обновляет поля и их внутренние данные, формирует единую команду действий для всех полей, в том числе возврат SugarMsg @@ -330,81 +368,115 @@ func (m *FormModel) updateFields(msg tea.Msg) tea.Cmd { return tea.Batch(cmds...) } -func (m *FormModel) initViewport(width, height int) { - _, _, contentWidth, contentHeight := ui.ContentSizes(width, height) - formWidth := contentWidth - 2*1 - formHeight := contentHeight - 2*4 - m.viewport = viewport.New(contentWidth, formHeight) - m.viewport.SetHorizontalStep(1) - m.rebuildContent(formWidth) -} - -func (m *FormModel) rebuildContent(width int) { - var strokes []string - if m.CurrentSection.Type == types.SectionAbout { - strokes = append(strokes, simpleAboutRender(m.CurrentSection.Paragraphs, width)) - } else { - for i, field := range m.FieldModels { - //Строка с учетом переноса поля. - //Имеется ввиду, что если вводимое значение слишком длинное, то оно будет делиться на несколько строк. - fieldStroke := wordwrap.String(field.View(), width) - //Если фокус равен текущему полю, то выделяем его. - if i == m.focus { - strokes = append(strokes, ui.FormFieldFocused.Render(fieldStroke)) - } else { - strokes = append(strokes, ui.FormFieldUnfocused.Render(fieldStroke)) - } - } - } - //Строка кнопки переключения секций. - //Если позиция фокуса равняется количеству полей, значит он сейчас находится на кнопке. - nextBtn := "[ next section ]" - if m.focus == len(m.FieldModels) { - strokes = append(strokes, ui.FormNextButton.Render(nextBtn)) - } else { - strokes = append(strokes, ui.FormFieldUnfocused.Render(nextBtn)) - } - m.renderedContent = strings.Join(strokes, "\n\n") - m.viewport.SetContent(m.renderedContent) -} - -// simpleAboutRender формирует единую строку, описывающую настраиваемое приложение. -func simpleAboutRender(paragraphs []string, width int) string { - var sb strings.Builder - for _, p := range paragraphs { - p = strings.TrimSpace(p) - if p == "" { - sb.WriteString("\n") - continue - } - switch { - case strings.HasPrefix(p, "# "): - // Заголовок 1 уровня - //h1Len := utf8.RuneCountInString(p[2:]) - //h1Offset := strings.Repeat(" ", (width-h1Len)/2) - //sb.WriteString(ui.AboutTitle.Render(h1Offset+p[2:]) + "\n\n") - sb.WriteString(ui.AboutTitle.Render(p[2:]) + "\n\n") - case strings.HasPrefix(p, "## "): - // Заголовок 2 уровня - sb.WriteString(ui.AboutH2.Render(p[3:]) + "\n\n") - case strings.HasPrefix(p, "### "): - // Заголовок 3 уровня - sb.WriteString(" " + ui.AboutH3.Render(p[4:]) + "\n\n") - case strings.HasPrefix(p, "- "), strings.HasPrefix(p, "* "): - // Список простой - text := strings.TrimLeft(p[2:], " ") - wrapped := wordwrap.String(text, width-10) - sb.WriteString(" " + ui.AboutBullet.Render("• ") + ui.AboutNormal.Render(wrapped) + "\n") - case strings.HasPrefix(p, "> "): - // Цитаты - text := strings.TrimPrefix(p, "> ") - wrapped := wordwrap.String(text, width-12) - sb.WriteString(" " + ui.AboutNormal.Foreground(ui.ColorGrey).Render("│ "+wrapped) + "\n") - default: - // Обычный текст - wrapped := wordwrap.String(p, width-8) - sb.WriteString(" " + ui.AboutNormal.Render(wrapped) + "\n\n") - } - } - return sb.String() -} +//// Update отслеживает действия с формой +//func (m *FormModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { +// +// switch msg := msg.(type) { +// case tea.WindowSizeMsg: +// //Отслеживаем и записываем новые размеры окна, обновляем рендер about +// m.width = msg.Width +// m.height = msg.Height +// m.initViewport(m.width, m.height) +// //if m.CurrentSection.Type == types.SectionAbout { +// // m.renderedContent = simpleAboutRender(m.CurrentSection.Paragraphs, m.width) +// //} +// return m, nil +// case tea.KeyMsg: +// //Отслеживаем нажатие клавиш формы +// switch msg.String() { +// case "esc": +// //Обработчик клавиши escape на форме нужен для закрытия полей поддерживающих открытие. +// //Обходим все доступные в текущей форме поля и закрываем те, что были открыты. +// //Проверять на фокус не нужно, так как не предполагается, что у нас может быть открыто сразу несколько полей. +// //Поэтому просто возвращаем команду по возврату сообщение об обновлении полей. +// for _, field := range m.FieldModels { +// //проверяем открыто ли поле +// if field.CaptureKeys() { +// //обновляем поля если оно открыто +// return m, m.updateFields(msg) +// } +// } +// //Если открытых полей не было найдено, значит escape была нажата для закрытия настройки, +// //возвращаем команду по возврату сообщения об открытии модального окна +// return m, func() tea.Msg { +// return types.ShowModalMsg{ +// Type: types.ModalWarning, +// Title: "Прервать настройку", +// Message: "Вы уверены, что хотите прервать настройку?\nВсе введенные данные будут потеряны.", +// ModalButtons: []types.ModalButton{ +// {Text: "Да, выйти", Value: true}, +// {Text: "Продолжить настройку", Value: false}, +// }, +// OnResult: func(result any) tea.Cmd { +// if result == true { +// return func() tea.Msg { return types.BackToListMsg{} } +// } +// return nil +// }, +// } +// } +// case "enter": +// //Обработчик клавиши enter на форме нужен для перехода на следующую секцию. +// //Проверяем где сейчас находится фокус, если его позиция равняется количеству полей, +// //значит он вышел за пределы формы и находится на кнопке next section, что ставится последней. +// //После этого мы сбрасываем фокус и возвращаем команду по возврату сообщения о переходе на следующую секцию +// if len(m.FieldModels) == m.focus { +// m.focus = 0 +// return m, func() tea.Msg { +// return types.NextSectionMsg{} +// } +// } +// case "tab", "shift+tab", "down", "up": +// //Обработчик клавиш tab, shift+tab, down, up на форме нужен для перехода между объектами формы. +// //Получаем точное имя клавиши нажатия, после проверяем где сейчас находится фокус. +// //Если фокус меньше количества полей, то значит фокус сейчас на поле формы. +// // Подробнее. У нас фокус в двух состояниях так сказать, +// // фокус равен количество полей, значит он на кнопке следующей секции, +// // если меньше количества полей или равен 0, значит фокус на поле формы. +// // Если у нас нет полей на форме (секция About), значит количество полей равно 0, а фокус по умолчанию устанавливается 0. +// // Чтобы m.FieldModels[m.focus] вызвало ошибку "индекс вне массива", +// // нужно чтобы в условие m.focus < len(m.FieldModels) попал фокус меньше 0 (-1,-2 и тд). +// // Чтобы этого избежать, есть проверка, которая устанавливает m.focus на последнюю позицию (количество полей на форме). +// // Чтобы эта проверка дала сбой, нужно после неё прописать изменение фокуса формы на отрицательный принудительно, так как такое не может произойти в текущем коде. +// //Получаем имя поля (так как это старый фокус, то и поле на котором мы находимся это предыдущее поле, отсюда и название переменной), +// //Если поле не открыто, то мы можем перейти на следующее поле, увеличив или уменьшив фокус. +// //Если фокус равен количеству полей, значит он находится на кнопке переключения секций. Увеличиваем или уменьшаем фокус. +// key := msg.String() +// if m.focus < len(m.FieldModels) { +// prevField := m.FieldModels[m.focus] +// if !prevField.CaptureKeys() { +// if key == "tab" || key == "down" { +// m.focus++ +// } else if key == "shift+tab" || key == "up" { +// m.focus-- +// } +// } +// } else { +// if key == "tab" || key == "down" { +// m.focus++ +// } else if key == "shift+tab" || key == "up" { +// m.focus-- +// } +// } +// //Проверка правильности фокуса, чтобы не выйти за границы массива полей формы, необходимо проверить новое значение фокуса. +// //Если фокус стал больше чем количество полей формы, значит он вышел за пределы всей формы и находится уже даже ниже кнопки, +// //переводим его на первую позицию, то есть на 0. +// //Если фокус стал меньше 0, значит он вышел за пределы формы и находится выше первого поля формы, +// //переводим его на последнюю позицию, то есть на кнопку переключения секции, равную положение которой = количество полей на форме. +// if m.focus == len(m.FieldModels)+1 { +// m.focus = 0 +// } else if m.focus < 0 { +// m.focus = len(m.FieldModels) +// } +// } +// } +// //Если не было возвращено сообщений, значит действие было произведено с полем, а не с самой формой. +// //Возвращаем результат функции обновления полей. +// var cmd tea.Cmd +// if m.CurrentSection.Type == types.SectionAbout { +// m.viewport, cmd = m.viewport.Update(msg) +// return m, cmd +// } else { +// return m, m.updateFields(msg) +// } +//}