GoSetup-bubble/setup/form.go

505 lines
25 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package setup
import (
"bubble/setup/fields"
"bubble/setup/ui"
"bubble/setup/utils"
"bubble/types"
"fmt"
"github.com/charmbracelet/bubbles/key"
"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 //Фокус для отслеживания с каким объектом надо работать
content string //Строка хранения информации о приложении
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 !utils.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 := utils.SpecialValueHandle(sectionField.SpecialValue)
if strSpecial, ok := specialOptions.([]string); ok {
options = strSpecial
}
}
selected := fmt.Sprintf("%v", sectionField.Variable.Value)
validationFunc := utils.ValidateField(sectionField.Name, sectionField.VariableName, sectionField.Validate, application)
fieldModels = append(fieldModels, fields.NewSelectModel(sectionField.Name, selected, sectionField.Description, options, validationFunc))
case "check":
checked := false
if value, ok := sectionField.Variable.Value.(bool); ok {
checked = value
} else {
checked = sectionField.Variable.Value == "true"
}
validationFunc := utils.ValidateCheckField(sectionField.Name, sectionField.VariableName, sectionField.Validate, application)
fieldModels = append(fieldModels, fields.NewCheckboxModel(sectionField.Name, sectionField.Description, checked, validationFunc))
case "compute":
value, err := utils.ComputeValue(sectionField.Compute, application)
if err != nil {
value = fmt.Sprintf("Ошибка вычисления: %v", err)
}
fieldModels = append(fieldModels, fields.NewComputeModel(sectionField.Name, fmt.Sprintf("%v", value), sectionField.Description))
//compute расчет
case "file":
validationFunc := utils.ValidateField(sectionField.Name, sectionField.VariableName, sectionField.Validate, application)
fieldModels = append(fieldModels, fields.NewFileSelectModel(sectionField.Name, fmt.Sprintf("%v", sectionField.Variable.Value), sectionField.Description, sectionField.FileFieldExtensions, validationFunc))
case "directory":
validationFunc := utils.ValidateField(sectionField.Name, sectionField.VariableName, sectionField.Validate, application)
fieldModels = append(fieldModels, fields.NewDirSelectModel(sectionField.Name, fmt.Sprintf("%v", sectionField.Variable.Value), sectionField.Description, validationFunc))
default:
validationFunc := utils.ValidateField(sectionField.Name, sectionField.VariableName, sectionField.Validate, application)
fieldModels = append(fieldModels, fields.NewInputModel(sectionField.Name, fmt.Sprintf("%v", sectionField.Variable.Value), sectionField.Description, validationFunc))
}
}
//Устанавливаем фокус (фокус может быть не равен нулю, если новая форма сформирована после 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:
//Отслеживаем и записываем новые размеры окна, обновляем область действий
m.width = msg.Width
m.height = msg.Height
m.initViewport(m.width, m.height)
return m, nil
case tea.KeyMsg:
//Отслеживаем нажатие клавиш формы
switch {
case key.Matches(msg, types.MainKeys.Close):
//Обработчик клавиши закрытия на форме нужен для закрытия полей поддерживающих открытие или закрытие формы настройки.
//Обходим все доступные в текущей форме поля и закрываем те, что были открыты.
//Проверять на фокус не нужно, так как не предполагается, что у нас может быть открыто сразу несколько полей.
//Поэтому просто возвращаем команду по возврату сообщение об обновлении полей.
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 key.Matches(msg, types.MainKeys.Confirm):
//Обработчик клавиши подтверждения на форме, нужен для перехода на следующую секцию.
//Проверяем где сейчас находится фокус, если его позиция равняется количеству полей,
//значит он вышел за пределы формы и находится на кнопке next section, что ставится последней.
//После этого мы сбрасываем фокус и возвращаем команду по возврату сообщения о переходе на следующую секцию
if len(m.FieldModels) == m.focus {
m.focus = 0
return m, func() tea.Msg {
return types.NextSectionMsg{}
}
}
case key.Matches(msg, types.MainKeys.Up):
//Обработчик клавиши перехода на предыдущий элемент формы, нужен для смены фокуса на прошлый элемент.
//Если фокус меньше количества полей, то значит фокус сейчас на поле формы.
//Если поле не открыто, то уменьшаем фокус.
//Если поле открыто, то передаем сообщение в это поле.
//Если фокус стал меньше 0, то присваиваем ему индекс кнопки секции и
//возвращаем команду по возврату сообщения прокручивания в самый низ.
if m.focus < len(m.FieldModels) {
if !m.FieldModels[m.focus].CaptureKeys() {
m.focus--
} else {
return m, m.updateFields(msg)
}
} else {
m.focus--
}
if m.focus < 0 {
m.focus = len(m.FieldModels)
return m, func() tea.Msg {
return types.ViewportScrollMsg{
Direction: "down",
}
}
}
case key.Matches(msg, types.MainKeys.Down):
//Обработчик клавиши перехода на следующий элемент формы, нужен для смены фокуса на следующий элемент.
//Если фокус меньше количества полей, то значит фокус сейчас на поле формы.
//Если поле не открыто, то увеличиваем фокус.
//Если поле открыто, то передаем сообщение в это поле.
//Если фокус стал больше количества полей формы, то присваиваем ему стартовый индекс и
//возвращаем команду по возврату сообщения прокручивания в самый верх.
if m.focus < len(m.FieldModels) {
if !m.FieldModels[m.focus].CaptureKeys() {
m.focus++
} else {
return m, m.updateFields(msg)
}
} else {
m.focus++
}
if m.focus == len(m.FieldModels)+1 {
m.focus = 0
return m, func() tea.Msg {
return types.ViewportScrollMsg{
Direction: "up",
}
}
}
}
//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 {
// return m, m.updateFields(msg)
// }
// } 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
// return m, func() tea.Msg {
// return types.ViewportScrollMsg{
// Direction: "up",
// }
// }
// } else if m.focus < 0 {
// m.focus = len(m.FieldModels)
// return m, func() tea.Msg {
// return types.ViewportScrollMsg{
// Direction: "down",
// }
// }
// }
//}
case types.ViewportScrollMsg:
if msg.Direction == "up" {
m.viewport.GotoTop()
} else {
m.viewport.GotoBottom()
}
}
var viewportCmd, fieldsCmd tea.Cmd
m.viewport, viewportCmd = m.viewport.Update(msg)
fieldsCmd = m.updateFields(msg)
return m, tea.Sequence(viewportCmd, fieldsCmd)
}
func (m *FormModel) View() string {
containerWidth, containerHeight, contentWidth, _ := ui.ContentSizes(m.width, m.height)
m.rebuildContent(contentWidth)
container := ui.AppContainer.
Width(containerWidth).
Height(containerHeight).
Render(m.viewport.View())
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)
m.viewport = viewport.New(contentWidth, contentHeight)
m.viewport.SetHorizontalStep(1)
m.rebuildContent(contentWidth)
}
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)
//Если фокус равен текущему полю, то выделяем его.
var finalStroke string
if i == focus {
finalStroke = ui.FormFieldFocused.Render(fieldStroke)
} else {
finalStroke = ui.FormFieldUnfocused.Render(fieldStroke)
}
if i != len(fields)-1 {
finalStroke += "\n\n"
} else {
finalStroke += "\n"
}
sb.WriteString(finalStroke)
}
return sb.String()
}
// simpleAboutRender формирует единую строку, описывающую настраиваемое приложение.
func simpleAboutRender(paragraphs []string, width int) string {
var sb strings.Builder
for i, p := range paragraphs {
p = strings.TrimSpace(p)
if p == "" {
sb.WriteString("\n")
continue
}
var finalStroke, strokeType string
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)
finalStroke = ui.AboutTitle.Render(wrapped)
strokeType = "header1"
case strings.HasPrefix(p, "## "):
// Заголовок 2 уровня
text := strings.TrimPrefix(p, "## ")
wrapped := wordwrap.String(text, width)
finalStroke = ui.AboutH2.Render(wrapped)
strokeType = "header2"
case strings.HasPrefix(p, "### "):
// Заголовок 3 уровня
text := strings.TrimPrefix(p, "### ")
wrapped := wordwrap.String(text, width)
finalStroke = ui.AboutH3.Render(wrapped)
strokeType = "header3"
case strings.HasPrefix(p, "- "), strings.HasPrefix(p, "* "):
// Список простой
text := strings.TrimLeft(p[2:], " ")
wrapped := wordwrap.String(text, width)
finalStroke = " " + ui.AboutBullet.Render("• ") + ui.AboutNormal.Render(wrapped)
strokeType = "bullet"
case strings.HasPrefix(p, "> "):
// Цитаты
text := strings.TrimPrefix(p, "> ")
wrapped := wordwrap.String(text, width)
finalStroke = " " + ui.AboutNormal.Foreground(ui.ColorGrey).Render("│ "+wrapped)
strokeType = "quote"
default:
// Обычный текст
wrapped := wordwrap.String(p, width)
finalStroke = " " + ui.AboutNormal.Render(wrapped)
strokeType = "normal"
}
if i != len(paragraphs)-1 {
switch strokeType {
case "normal", "quote", "bullet":
finalStroke += "\n"
case "header1", "header2", "header3":
finalStroke += "\n\n"
}
} else {
finalStroke += "\n"
}
sb.WriteString(finalStroke)
}
return sb.String()
}
// 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,
Focus: m.focus,
}
})
}
}
}
}
//Если есть команда поля, то также помещаем её в массив команд
if fieldCmd != nil {
cmds = append(cmds, fieldCmd)
}
}
//Объединяем весь массив команду в одну
return tea.Sequence(cmds...)
}