package setup import ( "bubble/setup/fields" "bubble/setup/ui" "bubble/types" "fmt" "github.com/charmbracelet/bubbles/viewport" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "github.com/muesli/reflow/wordwrap" "github.com/sirupsen/logrus" "strings" ) // FormModel структура описывающая модель отрисовки формы настройки type FormModel struct { CurrentSection *types.Section //Указатель секции с которой надо работать FieldModels []fields.FieldModel //Массив моделей полей focus int //Фокус для отслеживания с каким объектом надо работать renderedContent string //Строка хранения информации о приложении // todo поместить во viewport viewport viewport.Model //Объект куда помещена информация о приложении для возможности прокручивать её width int //Ширина окна height int //Высота окна } // NewFormModel создает новую модель формы на основе данных настраиваемого приложения func NewFormModel(application *types.PackageApplication, sectionIdx, focus, width, height int) *FormModel { //Получаем секцию которую надо отрисовать currentSection := &application.Sections[sectionIdx] //Заполняем массив моделей полей с учетом условий var fieldModels []fields.FieldModel for _, sectionField := range currentSection.Fields { if !CheckCondition(sectionField.Condition, application) { continue } switch sectionField.FieldType { case "select": var options []string for j := range sectionField.Options { options = append(options, fmt.Sprintf("%v", sectionField.Options[j])) } if sectionField.SpecialValue != "" { specialOptions := SpecialValueHandle(sectionField.SpecialValue) if strSpecial, ok := specialOptions.([]string); ok { options = strSpecial } } selected := fmt.Sprintf("%v", sectionField.Variable.Value) fieldModels = append(fieldModels, fields.NewSelectModel(sectionField.Name, selected, options)) case "check": checked := false if value, ok := sectionField.Variable.Value.(bool); ok { checked = value } else { checked = sectionField.Variable.Value == "true" } fieldModels = append(fieldModels, fields.NewCheckboxModel(sectionField.Name, 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))) //compute расчет case "file": fieldModels = append(fieldModels, fields.NewFileSelectModel(sectionField.Name, fmt.Sprintf("%v", sectionField.Variable.Value), sectionField.FileFieldExtensions)) case "directory": fieldModels = append(fieldModels, fields.NewDirSelectModel(sectionField.Name, fmt.Sprintf("%v", sectionField.Variable.Value))) default: fieldModels = append(fieldModels, fields.NewInputModel(sectionField.Name, fmt.Sprintf("%v", sectionField.Variable.Value))) } } //Устанавливаем фокус (фокус может быть не равен нулю, если новая форма сформирована после Sugar события, который обновляет форму в ТЕКУЩЕМ состоянии) if len(fieldModels) > focus { fieldModels[focus].Focus() } m := &FormModel{ FieldModels: fieldModels, focus: focus, CurrentSection: currentSection, width: width, height: height, } ////Формируем описание приложения если секция установлена About //if currentSection.Type == types.SectionAbout { // m.renderedContent = simpleAboutRender(currentSection.Paragraphs, width) // m.focus = 0 //} if width > 0 && height > 0 { m.initViewport(width, height) } return m } func (m *FormModel) Init() tea.Cmd { return nil } // 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) } } // View формирует строку для вывода формы и её контента. func (m *FormModel) View() string { containerWidth, containerHeight, contentWidth, contentHeight := ui.ContentSizes(m.width, m.height) formWidth := contentWidth - 2*1 //formHeight:=contentHeight-2*1 _ = contentHeight - 2*1 m.rebuildContent(formWidth) container := ui.AppContainer. Width(containerWidth). Height(containerHeight). Render(m.renderedContent) return lipgloss.Place( m.width, m.height, lipgloss.Center, lipgloss.Center, container, lipgloss.WithWhitespaceChars("0"), lipgloss.WithWhitespaceForeground(ui.ColorError), ) ////Данные о размерах контейнера и ширины контента внутри //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(" "), //) } // updateFields обновляет поля и их внутренние данные, формирует единую команду действий для всех полей, в том числе возврат SugarMsg func (m *FormModel) updateFields(msg tea.Msg) tea.Cmd { //Массив команд для выполнения var cmds []tea.Cmd for i, fieldModel := range m.FieldModels { var fieldCmd tea.Cmd var updated fields.FieldModel //Если фокус установлен на текущем поле, то нужно активировать его и передать ему сообщение для обновления. //Если фокус не установлен на текущем поле, то снимаем с него фокус и передаем ему сообщение не связанное с нажатием кнопок клавиатуры if i == m.focus { fieldModel.Focus() updated, fieldCmd = fieldModel.Update(msg) } else { fieldModel.Blur() if _, ok := msg.(tea.KeyMsg); !ok { updated, fieldCmd = fieldModel.Update(msg) } } //Если поле успешно вернуло обновленное поле, то необходимо: //обновить модель поля в текущей форме; //проверить у поля не типа Compute изменилось ли значение переменной в настройках приложения, //если изменилось, в массив команд помещаем команду по возврату сообщения Sugar if updated != nil { m.FieldModels[i] = updated for _, sectionField := range m.CurrentSection.Fields { if sectionField.Name == fieldModel.GetLabel() && sectionField.FieldType != "compute" { oldValue := sectionField.Variable.Value newValue := fieldModel.GetValue() logrus.Debugf("label %s, oldValue(%v,%T), newValue(%v,%T), equal %t, field info %+v", sectionField.Name, oldValue, oldValue, newValue, newValue, oldValue == newValue, sectionField, ) if oldValue != newValue { sectionField.Variable.Value = newValue //Записываем в переменную новое значение cmds = append(cmds, func() tea.Msg { return types.SugarMsg{ VariableName: sectionField.VariableName, FieldName: sectionField.Name, Compute: sectionField.Compute, Focus: m.focus, } }) } } } } //Если есть команда поля, то также помещаем её в массив команд if fieldCmd != nil { cmds = append(cmds, fieldCmd) } } //Объединяем весь массив команду в одну 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() }