From e37cabadcca18e571e26a50ef17f0b95bd3bf4cb Mon Sep 17 00:00:00 2001 From: XeRo Date: Tue, 20 Jan 2026 17:40:21 +0500 Subject: [PATCH] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D0=B5=20=D0=B8=20=D0=B8=D1=81=D0=BF=D1=80=D0=B0?= =?UTF-8?q?=D0=B2=D0=BB=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=BA=D0=BE=D0=BC=D0=BC?= =?UTF-8?q?=D0=B5=D0=BD=D1=82=D0=B0=D1=80=D0=B8=D0=B5=D0=B2,=20=D0=BD?= =?UTF-8?q?=D0=B0=D1=87=D0=B0=D0=BB=D0=BE=20=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2?= =?UTF-8?q?=D0=BB=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=B2=D0=B0=D0=BB=D0=B8=D0=B4?= =?UTF-8?q?=D0=B0=D1=86=D0=B8=D0=B8,=20=D1=81=D0=BE=D0=BE=D0=B1=D1=89?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=BF=D1=80=D0=BE=D0=BA=D1=80=D1=83?= =?UTF-8?q?=D1=82=D0=B8=20viewport=20=D0=BD=D0=B0=20=D1=84=D0=BE=D1=80?= =?UTF-8?q?=D0=BC=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- confs/repko.yaml | 10 +- setup/condition.go | 76 +++++++++++--- setup/fields/checkbox.go | 43 +++++--- setup/fields/compute.go | 15 +-- setup/fields/dirSelect.go | 67 +++++++------ setup/fields/fieldModel.go | 1 + setup/fields/fileSelect.go | 30 ++---- setup/fields/input.go | 10 +- setup/fields/select.go | 49 +++++++--- setup/form.go | 196 ++++++++++++------------------------- setup/main.go | 2 +- setup/utils/utils.go | 31 ++++++ types/msg.go | 35 ++++--- types/types.go | 103 ++++++++++--------- 14 files changed, 371 insertions(+), 297 deletions(-) create mode 100644 setup/utils/utils.go diff --git a/confs/repko.yaml b/confs/repko.yaml index f8885c9..b896762 100644 --- a/confs/repko.yaml +++ b/confs/repko.yaml @@ -159,6 +159,9 @@ sections: variable-name: "is_secure" field-type: "check" description: "Использовать SSL" + validate: + - rule: "min(1,nginx_port)" + message: "Значение слишком большое" - name: "server_address" variable-name: "server_address" field-type: "input" @@ -284,6 +287,9 @@ sections: variable-name: "debian_root" field-type: "directory" description: "Директория debian" + validate: + - rule: "dir_exists(debian_root)" + message: "Папка не существует" - name: "finale" description: "Последний этап, проверьте правильность введенных данных" type: "final" @@ -307,8 +313,8 @@ sections: error: "Ошибка при создании файла repko.target.service" - name: "update_repko_ini" type: "file_template" -# file-source: "/home/test/Projects/Personal/GoSetup/_public/files/donor/config.donor.ini" -# file-target: "/home/test/Projects/Personal/GoSetup/_public/files/target/config.target.ini" + # file-source: "/home/test/Projects/Personal/GoSetup/_public/files/donor/config.donor.ini" + # file-target: "/home/test/Projects/Personal/GoSetup/_public/files/target/config.target.ini" file-source: "http://gsg.site/_public/config.donor.ini" file-target: "/home/test/Projects/Personal/GoSetup/_public/files/target/http.config.ini" error: "Ошибка при создании файла repko.target.service" diff --git a/setup/condition.go b/setup/condition.go index 58be7c6..6ac0c05 100644 --- a/setup/condition.go +++ b/setup/condition.go @@ -2,10 +2,13 @@ package setup import ( "bubble/types" + "errors" "fmt" + "github.com/charmbracelet/bubbles/textinput" "github.com/expr-lang/expr" "github.com/sirupsen/logrus" "io" + "io/fs" "net/http" "os" "os/exec" @@ -18,8 +21,7 @@ import ( // userRE - с начала строки, делит все по знаку двоеточия, берется первые 3 группы, пример user_name:x:1000: var userRE = regexp.MustCompile(`^([^:]+):[^:]*:([0-9]+):`) -// ParamsForCondition — собирает все текущие значения переменных приложения -// для использования в expr-условиях (condition) +// ParamsForCondition получение словаря переменных с приведением типов для проверки условий func ParamsForCondition(app *types.PackageApplication) map[string]interface{} { params := make(map[string]interface{}) @@ -77,7 +79,7 @@ func ParamsForCondition(app *types.PackageApplication) map[string]interface{} { return params } -// CheckCondition — проверяет условие visibility (condition) для секции или поля +// CheckCondition проверяет равно ли переданное условие func CheckCondition(condition string, app *types.PackageApplication) bool { if condition == "" { return true @@ -99,7 +101,7 @@ func CheckCondition(condition string, app *types.PackageApplication) bool { return boolResult } -// ParamsForCompute — аналогично, но только по переменным (как в твоём старом коде) +// ParamsForCompute получение словаря переменных без приведения типов, для вычисления func ParamsForCompute(app *types.PackageApplication) map[string]interface{} { params := make(map[string]interface{}) @@ -129,7 +131,7 @@ func ParamsForCompute(app *types.PackageApplication) map[string]interface{} { return params } -// ComputeValue — вычисляет значение для compute-поля +// ComputeValue вычисляет значение для поля compute. func ComputeValue(computeExpr string, app *types.PackageApplication) (interface{}, error) { if computeExpr == "" { return nil, nil @@ -172,6 +174,44 @@ func Recompute(app *types.PackageApplication) { } } +// ValidateField создает функцию типа textinput.ValidateFunc для проверки значения полей +func ValidateField(fieldName, variableName string, validations []types.FieldValidate, app *types.PackageApplication) textinput.ValidateFunc { + if len(validations) == 0 { + return nil + } + return func(text string) error { + env := ParamsForCompute(app) + env[variableName] = text //todo text это строка, если использовать функцию типа textinput.ValidateFunc, то словим ошибку типов + options := append([]expr.Option{ + expr.Env(env), + expr.AllowUndefinedVariables(), + }, DefineExprCustomFunctions()...) + for _, validate := range validations { + program, err := expr.Compile(validate.Rule, options...) + prefix := fmt.Sprintf("Валидация поля %s,", fieldName) + if err != nil { + msg := fmt.Errorf("ошибка компиляции правила: %s, err: %v", validate.Rule, err) + logrus.Errorf("%s %v", prefix, msg) + return msg + } + result, err := expr.Run(program, env) + if err != nil { + msg := fmt.Errorf("ошибка выполнения правила: %s, err: %v", validate.Rule, err) + logrus.Errorf("%s %v", prefix, msg) + return msg + } + if boolValue, ok := result.(bool); ok && !boolValue { + return fmt.Errorf("%s", validate.Message) + } else if !ok { + msg := fmt.Errorf("возвращаемое значение правила: %s не булево", validate.Rule) + logrus.Errorf("%s %v", prefix, msg) + return msg + } + } + return nil + } +} + // DefineExprCustomFunctions возвращает собственные функции expr func DefineExprCustomFunctions() []expr.Option { return []expr.Option{ @@ -270,8 +310,17 @@ func DefineExprCustomFunctions() []expr.Option { return false, nil } path := fmt.Sprintf("%v", args[0]) - _, err := os.Stat(path) - return err == nil, nil + fileInfo, err := os.Stat(path) + if err == nil && !fileInfo.IsDir() { + return true, nil + } else if err == nil && fileInfo.IsDir() { + return false, fmt.Errorf("file is dir") + } + if errors.Is(err, fs.ErrNotExist) { + return false, fmt.Errorf("no such directory or file") + } else { + return false, err + } }, new(func(string) bool), ), //проверяет наличие директории в системе @@ -282,13 +331,16 @@ func DefineExprCustomFunctions() []expr.Option { } path := fmt.Sprintf("%v", args[0]) fileInfo, err := os.Stat(path) - if err != nil { - return nil, err - } - if fileInfo.IsDir() { + if err == nil && fileInfo.IsDir() { return true, nil + } else if err == nil && !fileInfo.IsDir() { + return false, fmt.Errorf("file is not a dir") + } + if errors.Is(err, fs.ErrNotExist) { + return false, fmt.Errorf("no such directory or file") + } else { + return false, err } - return false, nil }, new(func(string) bool), ), //проверяет доступность порта diff --git a/setup/fields/checkbox.go b/setup/fields/checkbox.go index 83b0cee..99d191e 100644 --- a/setup/fields/checkbox.go +++ b/setup/fields/checkbox.go @@ -2,47 +2,62 @@ package fields import ( "fmt" + "github.com/charmbracelet/bubbles/textinput" tea "github.com/charmbracelet/bubbletea" + "strconv" ) +// CheckboxModel структура описывающая модель выбора из двух состояний да и нет type CheckboxModel struct { - Label string - description string - Checked bool + label string //Имя поля + description string //Описание поля + checked bool //Выбранное состояние + validate textinput.ValidateFunc //Функция валидации + err error //Поле ошибки валидации } -func NewCheckboxModel(label, description string, checked bool) *CheckboxModel { - return &CheckboxModel{ - Label: label, +// NewCheckboxModel создаем новую модель выбора +func NewCheckboxModel(label, description string, checked bool, validator textinput.ValidateFunc) *CheckboxModel { + checkbox := &CheckboxModel{ + label: label, description: description, - Checked: checked, + validate: validator, + checked: checked, } + checkbox.err = validator(strconv.FormatBool(checked)) + + return checkbox } func (m *CheckboxModel) Init() tea.Cmd { return nil } +// Update отслеживает действия с полем func (m *CheckboxModel) Update(msg tea.Msg) (FieldModel, tea.Cmd) { switch msg := msg.(type) { case tea.KeyMsg: + //Отслеживает нажатия клавиш. switch msg.String() { case " ", "enter": - m.Checked = !m.Checked + //Если был нажат enter или space, то меняем текущее значение на противоположное + m.checked = !m.checked + m.err = m.validate(strconv.FormatBool(m.checked)) } } return m, nil } func (m *CheckboxModel) View() string { - res := "" - res += m.Label + ": " - if m.Checked { + res := fmt.Sprintf("%s: ", m.label) + if m.checked { res += "[x]" } else { res += "[ ]" } - if m.description != "" { + if m.err != nil { + res += fmt.Sprintf("\nОшибка валидации: %s", m.err) + } else if m.description != "" { res += fmt.Sprintf("\n%s", m.description) } return res @@ -62,9 +77,9 @@ func (m *CheckboxModel) CaptureKeys() bool { } func (m *CheckboxModel) GetLabel() string { - return m.Label + return m.label } func (m *CheckboxModel) GetValue() interface{} { - return m.Checked + return m.checked } diff --git a/setup/fields/compute.go b/setup/fields/compute.go index f671b73..5c7aafe 100644 --- a/setup/fields/compute.go +++ b/setup/fields/compute.go @@ -5,15 +5,17 @@ import ( tea "github.com/charmbracelet/bubbletea" ) +// ComputeModel структура описывающая модель вывода вычисляемых значений type ComputeModel struct { - Label string - description string - value string + label string //Имя поля + description string //Описание поля + value string //Значение поля } +// NewComputeModel создаем новую модель вывода вычисляемых значений func NewComputeModel(label, value, description string) *ComputeModel { return &ComputeModel{ - Label: label, + label: label, description: description, value: value, } @@ -23,12 +25,13 @@ func (m *ComputeModel) Init() tea.Cmd { return nil } +// Update отслеживает действия с полем. Так как поле нужно только для вывода, никаких действий не выполняем. func (m *ComputeModel) Update(msg tea.Msg) (FieldModel, tea.Cmd) { return m, nil } func (m *ComputeModel) View() string { - res := 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) } @@ -46,7 +49,7 @@ func (m *ComputeModel) CaptureKeys() bool { } func (m *ComputeModel) GetLabel() string { - return m.Label + return m.label } func (m *ComputeModel) GetValue() interface{} { diff --git a/setup/fields/dirSelect.go b/setup/fields/dirSelect.go index 9ec317b..bb4ca59 100644 --- a/setup/fields/dirSelect.go +++ b/setup/fields/dirSelect.go @@ -2,6 +2,7 @@ package fields import ( "bubble/setup/ui" + "bubble/setup/utils" "fmt" "github.com/charmbracelet/bubbles/textinput" tea "github.com/charmbracelet/bubbletea" @@ -11,20 +12,23 @@ import ( "strings" ) +// DirSelectModel структура описывающая модель поля выбора директории type DirSelectModel struct { - input textinput.Model - label string - description string - focus int - open bool - dir string - currentDirectory string - options []os.FileInfo - optionsCount int + input textinput.Model //Готовый компонент input для ввода данных + label string //Имя поля + description string //Описание поля + focus int //Фокус для управления опциями + open bool //Открыт ли список + dir string //Имя выбранной директории + currentDirectory string //Директория текущего файла + options []os.FileInfo //Элементы выбора файлов + optionsCount int //Количество элементов к показу } -func NewDirSelectModel(label, dir, description string) *DirSelectModel { +// NewDirSelectModel создает новую модель поля выбора директории +func NewDirSelectModel(label, dir, description string, validator textinput.ValidateFunc) *DirSelectModel { inputModel := textinput.New() + inputModel.Validate = validator inputModel.SetValue(dir) return &DirSelectModel{ input: inputModel, @@ -41,27 +45,37 @@ func (m *DirSelectModel) Init() tea.Cmd { return textinput.Blink } +// Update отслеживает действия с полями func (m *DirSelectModel) 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) { + //Выбираем директорию из списка. Если список пуст ничего не делаем. + //Получаем имя файла. + //Если имя файла ".." возвращаемся в родительскую папку. + //Если файл является директорией, то записываем его и закрываем список. + if len(m.options) == 0 || m.focus >= len(m.options) { return m, nil } file := m.options[m.focus] @@ -88,6 +102,8 @@ func (m *DirSelectModel) Update(msg tea.Msg) (FieldModel, tea.Cmd) { } } } else { + //Если список не открыт и был нажат enter, открываем его и рисуем опции списка. + //Иначе просто передаем сообщения нажатия клавиш в input. switch msg.String() { case "enter": m.open = true @@ -115,6 +131,7 @@ func (m *DirSelectModel) Update(msg tea.Msg) (FieldModel, tea.Cmd) { } } default: + //Все прочие виды сообщений передаем в input если список закрыт if !m.open { var cmd tea.Cmd m.input, cmd = m.input.Update(msg) @@ -126,27 +143,13 @@ 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 != "" { + if m.input.Err != nil && !m.open { + res += fmt.Sprintf("\nОшибка валидации: %s", m.input.Err) + } else 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) { - visibleCount = len(m.options) - } - half := visibleCount / 2 - start := m.focus - half - if start < 0 { - start = 0 - } - end := start + visibleCount - if end > len(m.options) { - end = len(m.options) - start = end - visibleCount - if start < 0 { - start = 0 - } - } + start, end := utils.CalculateSelectOptionsIndex(m.focus, m.optionsCount, len(m.options)) for i := start; i < end; i++ { file := m.options[i] name := file.Name() @@ -188,21 +191,26 @@ func (m *DirSelectModel) GetValue() interface{} { return m.dir } +// RenderOptions заполняем список опций на основе директорий func (m *DirSelectModel) RenderOptions() { + //Читаем файлы к текущей директории filesInDir, err := os.ReadDir(m.currentDirectory) if err != nil { logrus.Errorf("Ошибка чтения директории: %v", err) return } m.options = nil + //Если это не корень файловой системы, добавляем переход на родительскую директорию var allOptions []os.FileInfo if m.currentDirectory != "/" && filepath.Clean(m.currentDirectory) != "/" { allOptions = append(allOptions, virtualDirInfo{name: ".."}) } + //Заполняем прочие опции for _, entry := range filesInDir { info, _ := entry.Info() allOptions = append(allOptions, info) } + //Заполняем фильтр для опций на основе значения поля ввода filter := "" if m.open { inputValue := strings.TrimSpace(m.input.Value()) @@ -213,6 +221,7 @@ func (m *DirSelectModel) RenderOptions() { } } } + //Чистим опции и заполняем их правильными значениями m.options = m.options[:0] for _, opt := range allOptions { name := opt.Name() diff --git a/setup/fields/fieldModel.go b/setup/fields/fieldModel.go index 1cb48e0..0de2e10 100644 --- a/setup/fields/fieldModel.go +++ b/setup/fields/fieldModel.go @@ -2,6 +2,7 @@ package fields import tea "github.com/charmbracelet/bubbletea" +// FieldModel интерфейс работы с полями формы type FieldModel interface { Init() tea.Cmd //Метод инициализации поля Update(msg tea.Msg) (FieldModel, tea.Cmd) //Метод отслеживания событий связанных с моделью diff --git a/setup/fields/fileSelect.go b/setup/fields/fileSelect.go index 7eadd2e..043b585 100644 --- a/setup/fields/fileSelect.go +++ b/setup/fields/fileSelect.go @@ -2,6 +2,7 @@ package fields import ( "bubble/setup/ui" + "bubble/setup/utils" "fmt" "github.com/charmbracelet/bubbles/textinput" tea "github.com/charmbracelet/bubbletea" @@ -28,8 +29,9 @@ type FileSelectModel struct { } // NewFileSelectModel создаем новую модель поля выбора файла -func NewFileSelectModel(label, file, description string, extensions []string) *FileSelectModel { +func NewFileSelectModel(label, file, description string, extensions []string, validator textinput.ValidateFunc) *FileSelectModel { inputModel := textinput.New() + inputModel.Validate = validator inputModel.SetValue(file) return &FileSelectModel{ input: inputModel, @@ -78,7 +80,7 @@ func (m *FileSelectModel) Update(msg tea.Msg) (FieldModel, tea.Cmd) { //Если имя файла ".." возвращаемся в родительскую папку. //Если файл является директорией, то переходим в эту директорию. //Если у файла совпадают расширения, то записываем его и закрываем список. - if len(m.options) == 0 || m.focus == len(m.options) { + if len(m.options) == 0 || m.focus >= len(m.options) { return m, nil } file := m.options[m.focus] @@ -112,7 +114,7 @@ func (m *FileSelectModel) Update(msg tea.Msg) (FieldModel, tea.Cmd) { } } } else { - //Если список не открыт и был нажат enter открываем его и рисуем опции списка. + //Если список не открыт и был нажат enter, открываем его и рисуем опции списка. //Иначе просто передаем сообщение нажатия клавиш в input. switch msg.String() { case "enter": @@ -142,27 +144,13 @@ 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 != "" { + if m.input.Err != nil && !m.open { + res += fmt.Sprintf("\nОшибка валидации: %s", m.input.Err) + } else 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) { //если количество отображаемых больше, чем есть опций на самом деле - visibleCount = len(m.options) //то меняем количество видимых на количество опций - } - half := visibleCount / 2 //количество объектов сверху и снизу - start := m.focus - half //стартовая позиция - if start < 0 { //если стартовая позиция меньше 0, то меняем на 0 - start = 0 - } - end := start + visibleCount //последний элемент - if end > len(m.options) { //если последний элемент вне опций - end = len(m.options) //меняем на количество опций - start = end - visibleCount //перерасчет стартовой позиции - if start < 0 { - start = 0 - } - } + start, end := utils.CalculateSelectOptionsIndex(m.focus, m.optionsCount, len(m.options)) for i := start; i < end; i++ { file := m.options[i] name := file.Name() diff --git a/setup/fields/input.go b/setup/fields/input.go index a06b069..232be38 100644 --- a/setup/fields/input.go +++ b/setup/fields/input.go @@ -14,9 +14,13 @@ type InputModel struct { input textinput.Model } +type FieldValidator func(value any) error + // NewInputModel создает новую модель поля ввода данных -func NewInputModel(label, value, description string) *InputModel { +func NewInputModel(label, value, description string, validator textinput.ValidateFunc) *InputModel { + t := textinput.New() + t.Validate = validator t.SetValue(value) t.Cursor.SetMode(cursor.CursorBlink) return &InputModel{ @@ -39,7 +43,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 != "" { + if m.input.Err != nil { + res += fmt.Sprintf("\nОшибка валидации: %s", m.input.Err) + } else 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 1883140..7b22179 100644 --- a/setup/fields/select.go +++ b/setup/fields/select.go @@ -1,21 +1,26 @@ package fields import ( + "bubble/setup/utils" "fmt" "github.com/charmbracelet/bubbles/textinput" tea "github.com/charmbracelet/bubbletea" ) +// SelectModel структура описывающая модель поля выбора опции type SelectModel struct { - label string - description string - options []string - focus int - open bool - chosen string + label string //Имя поля + description string //Описание пол + options []string //Элементы выбора + focus int //Фокус для управления опциями + open bool //Открыт ли список + chosen string //Выбранный элемент + optionsCount int //Количество элементов к показу } +// NewSelectModel создаем новую поля выбора опции func NewSelectModel(label, value, description string, options []string) *SelectModel { + //Проверка, что указанный по умолчанию элемент есть в списке опции found := false for i := range options { if options[i] == value { @@ -26,11 +31,12 @@ func NewSelectModel(label, value, description string, options []string) *SelectM value = "" } return &SelectModel{ - options: options, - label: label, - description: description, - focus: len(options), - chosen: value, + options: options, + label: label, + description: description, + focus: len(options), + chosen: value, + optionsCount: 10, } } @@ -42,30 +48,37 @@ func (m *SelectModel) Update(msg tea.Msg) (FieldModel, tea.Cmd) { var cmd 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) { + //Выбираем опцию из списка + if len(m.options) == 0 || m.focus >= len(m.options) { return m, nil } m.chosen = m.options[m.focus] m.open = false - m.focus = len(m.options) + m.focus = 0 return m, nil } } else { + //Если список не открыт и был нажат enter, открываем его и устанавливаем фокус на основе опций. switch msg.String() { case "enter": m.open = true @@ -91,12 +104,16 @@ func (m *SelectModel) View() string { res += fmt.Sprintf("\n%s", m.description) } if m.open && len(m.options) > 0 { - for i := range m.options { + start, end := utils.CalculateSelectOptionsIndex(m.focus, m.optionsCount, len(m.options)) + for i := start; i < end; i++ { + option := m.options[i] + prefix := "\n\t" if m.focus == i { - res += "\n\t-> " + m.options[i] + prefix += "-> " } else { - res += "\n\t " + m.options[i] + prefix += " " } + res += prefix + option } } return res diff --git a/setup/form.go b/setup/form.go index 1d91d8d..c1c42c9 100644 --- a/setup/form.go +++ b/setup/form.go @@ -55,7 +55,8 @@ func NewFormModel(application *types.PackageApplication, sectionIdx, focus, widt } else { checked = sectionField.Variable.Value == "true" } - fieldModels = append(fieldModels, fields.NewCheckboxModel(sectionField.Name, sectionField.Description, checked)) + validationFunc := ValidateField(sectionField.Name, sectionField.VariableName, sectionField.Validate, application) + fieldModels = append(fieldModels, fields.NewCheckboxModel(sectionField.Name, sectionField.Description, checked, validationFunc)) case "compute": value, err := ComputeValue(sectionField.Compute, application) if err != nil { @@ -64,11 +65,14 @@ func NewFormModel(application *types.PackageApplication, sectionIdx, focus, widt 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.Description, sectionField.FileFieldExtensions)) + validationFunc := 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": - fieldModels = append(fieldModels, fields.NewDirSelectModel(sectionField.Name, fmt.Sprintf("%v", sectionField.Variable.Value), sectionField.Description)) + validationFunc := 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: - fieldModels = append(fieldModels, fields.NewInputModel(sectionField.Name, fmt.Sprintf("%v", sectionField.Variable.Value), sectionField.Description)) + validationFunc := 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 события, который обновляет форму в ТЕКУЩЕМ состоянии) @@ -100,7 +104,6 @@ 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 @@ -189,15 +192,31 @@ func (m *FormModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } //Проверка правильности фокуса, чтобы не выйти за границы массива полей формы, необходимо проверить новое значение фокуса. //Если фокус стал больше чем количество полей формы, значит он вышел за пределы всей формы и находится уже даже ниже кнопки, - //переводим его на первую позицию, то есть на 0. + //переводим его на первую позицию, то есть на 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) @@ -225,11 +244,9 @@ func (m *FormModel) View() string { 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 = viewport.New(contentWidth, contentHeight) m.viewport.SetHorizontalStep(1) - m.rebuildContent(contentAreaWidth) + m.rebuildContent(contentWidth) } func (m *FormModel) rebuildContent(width int) { @@ -257,11 +274,18 @@ func simpleForm(fields []fields.FieldModel, focus, width int) string { //Имеется ввиду, что если вводимое значение слишком длинное, то оно будет делиться на несколько строк. fieldStroke := wordwrap.String(field.View(), width) //Если фокус равен текущему полю, то выделяем его. + var finalStroke string if i == focus { - sb.WriteString(ui.FormFieldFocused.Render(fieldStroke) + "\n\n") + finalStroke = ui.FormFieldFocused.Render(fieldStroke) } else { - sb.WriteString(ui.FormFieldUnfocused.Render(fieldStroke) + "\n\n") + finalStroke = ui.FormFieldUnfocused.Render(fieldStroke) } + if i != len(fields)-1 { + finalStroke += "\n\n" + } else { + finalStroke += "\n" + } + sb.WriteString(finalStroke) } return sb.String() } @@ -269,12 +293,13 @@ func simpleForm(fields []fields.FieldModel, focus, width int) string { // simpleAboutRender формирует единую строку, описывающую настраиваемое приложение. func simpleAboutRender(paragraphs []string, width int) string { var sb strings.Builder - for _, p := range paragraphs { + 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 уровня @@ -283,32 +308,49 @@ func simpleAboutRender(paragraphs []string, width int) string { //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") + finalStroke = ui.AboutTitle.Render(wrapped) + strokeType = "header1" case strings.HasPrefix(p, "## "): // Заголовок 2 уровня text := strings.TrimPrefix(p, "## ") wrapped := wordwrap.String(text, width) - sb.WriteString(ui.AboutH2.Render(wrapped) + "\n\n") + finalStroke = ui.AboutH2.Render(wrapped) + strokeType = "header2" case strings.HasPrefix(p, "### "): // Заголовок 3 уровня text := strings.TrimPrefix(p, "### ") wrapped := wordwrap.String(text, width) - sb.WriteString(ui.AboutH3.Render(wrapped) + "\n\n") + finalStroke = ui.AboutH3.Render(wrapped) + strokeType = "header3" 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") + finalStroke = " " + ui.AboutBullet.Render("• ") + ui.AboutNormal.Render(wrapped) + strokeType = "bullet" case strings.HasPrefix(p, "> "): // Цитаты text := strings.TrimPrefix(p, "> ") wrapped := wordwrap.String(text, width) - sb.WriteString(" " + ui.AboutNormal.Foreground(ui.ColorGrey).Render("│ "+wrapped) + "\n") + finalStroke = " " + ui.AboutNormal.Foreground(ui.ColorGrey).Render("│ "+wrapped) + strokeType = "quote" default: // Обычный текст wrapped := wordwrap.String(p, width) - sb.WriteString(" " + ui.AboutNormal.Render(wrapped) + "\n\n") + 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() } @@ -350,7 +392,6 @@ func (m *FormModel) updateFields(msg tea.Msg) tea.Cmd { return types.SugarMsg{ VariableName: sectionField.VariableName, FieldName: sectionField.Name, - Compute: sectionField.Compute, Focus: m.focus, } }) @@ -367,116 +408,3 @@ func (m *FormModel) updateFields(msg tea.Msg) tea.Cmd { //Объединяем весь массив команду в одну return tea.Batch(cmds...) } - -//// 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) -// } -//} diff --git a/setup/main.go b/setup/main.go index 30de334..e43b136 100644 --- a/setup/main.go +++ b/setup/main.go @@ -213,7 +213,7 @@ func (m *Main) Sugar(sugar types.SugarMsg) { } } -// IsParent проверяет являться ли указанная переменная условие для поля или секции +// IsParent проверяет, является ли указанная переменная условием для поля или секции func IsParent(variableName string, sections []types.Section) bool { for _, section := range sections { for _, parent := range section.Parent { diff --git a/setup/utils/utils.go b/setup/utils/utils.go new file mode 100644 index 0000000..3df6c23 --- /dev/null +++ b/setup/utils/utils.go @@ -0,0 +1,31 @@ +package utils + +// CalculateSelectOptionsIndex рассчитывает индекс первой и последней опции для отображения в поле выбора +func CalculateSelectOptionsIndex(focus, visibleCount, optionsCount int) (int, int) { + //Если количество отображаемых больше, чем есть количество опций на самом деле, + //то меняем количество опций для отображения на количество опций. + if visibleCount > optionsCount { + visibleCount = optionsCount + } + //Количество объектов сверху и снизу + half := visibleCount / 2 + //Индекс первой опции для показа в списке + start := focus - half + //Если индекс меньше 0, то фиксируем его на первой + if start < 0 { + start = 0 + } + //Индекс последней опции для показа в списке + end := start + visibleCount + //Если индекс больше, чем количество опций для вывода, + //то фиксируем его на последней опции. + //Производим перерасчет индекса первой опции + if end > optionsCount { + end = optionsCount + start = end - visibleCount + if start < 0 { + start = 0 + } + } + return start, end +} diff --git a/types/msg.go b/types/msg.go index afc48ae..72748e8 100644 --- a/types/msg.go +++ b/types/msg.go @@ -2,32 +2,43 @@ package types import tea "github.com/charmbracelet/bubbletea" +// SelectedAppMsg сообщение выбора приложения из списка приложений type SelectedAppMsg struct { - App PackageApplication + App PackageApplication //Объект выбранного приложения } +// BackToListMsg сообщение перехода к списку приложений type BackToListMsg struct{} +// NextSectionMsg сообщение перехода на следующую секцию type NextSectionMsg struct{} +// SugarMsg сообщение о возможной необходимости обновить форму type SugarMsg struct { - VariableName string - FieldName string - Compute string - Focus int + VariableName string //Имя переменной присвоенной измененному полю + FieldName string //Имя измененного поля + Focus int //Старый фокус формы } +// ShowModalMsg сообщение о показе модального окна type ShowModalMsg struct { - Type ModalType - Title string - Message string - ModalButtons []ModalButton - OnResult func(result any) tea.Cmd + Type ModalType //Тип модального окна + Title string //Заголовок модального окна + Message string //Сообщение модального окна + ModalButtons []ModalButton //Кнопки модального окна + OnResult func(result any) tea.Cmd //Функция для выполнения } +// ModalButton структура кнопки модального окна type ModalButton struct { - Text string - Value any + Text string //Текст кнопки + Value any //Значение кнопки } +// CloseModalMsg сообщение о закрытии модального окна type CloseModalMsg struct{} + +// ViewportScrollMsg сообщение прокрутки видимой области формы +type ViewportScrollMsg struct { + Direction string //Направление прокрутки +} diff --git a/types/types.go b/types/types.go index 8baa096..e62a380 100644 --- a/types/types.go +++ b/types/types.go @@ -1,70 +1,77 @@ package types +// PackageApplication структура описывающая настраиваемое приложение type PackageApplication struct { - Name string `yaml:"name"` - Version string `yaml:"version"` - Description string `yaml:"description"` - Icon string `yaml:"icon"` - Type string `yaml:"type"` - ConfFiles []ConfFile `yaml:"conf-files"` - Variables []Variable `yaml:"variables"` - Sections []Section `yaml:"sections"` - Changed bool `yaml:"-"` + Name string `yaml:"name"` //Имя приложения + Version string `yaml:"version"` //Версия приложения + Description string `yaml:"description"` //Описание приложения + Icon string `yaml:"icon"` //Иконка приложения + Type string `yaml:"type"` //Тип приложения + ConfFiles []ConfFile `yaml:"conf-files"` //Файлы конфигурации + Variables []Variable `yaml:"variables"` //Переменные настройки + Sections []Section `yaml:"sections"` //Секции настройки + Changed bool `yaml:"-"` //Вносились ли изменения } +// ConfFile структура описывающая используемый приложением конфигурационный файл type ConfFile struct { - File string `yaml:"file"` - Type string `yaml:"type"` - Name string `yaml:"name"` + File string `yaml:"file"` //Путь файла + Type string `yaml:"type"` //Тип файла + Name string `yaml:"name"` //Имя файла } +// Variable структура описывающая переменную для настройки приложения type Variable struct { - Name string `yaml:"name"` - Type string `yaml:"type"` - Default string `yaml:"default"` - Value interface{} `yaml:"-"` + Name string `yaml:"name"` //Имя переменной + Type string `yaml:"type"` //Тип значения переменной + Default string `yaml:"default"` //Значение по умолчанию + Value interface{} `yaml:"-"` //Используемое значение } +// Section структура описывающая секцию настройки приложения type Section struct { - Name string `yaml:"name"` - Type string `yaml:"type"` - Description string `yaml:"description"` - Fields []SectionField `yaml:"fields"` - Paragraphs []string `yaml:"paragraphs"` - Parent []string `yaml:"parent"` - Condition string `yaml:"condition"` - Actions []SectionAction `yaml:"actions"` + Name string `yaml:"name"` //Имя секции + Type string `yaml:"type"` //Тип секции + Description string `yaml:"description"` //Описание секции + Fields []SectionField `yaml:"fields"` //Поля секции + Paragraphs []string `yaml:"paragraphs"` //Параграфы для описания приложения + Parent []string `yaml:"parent"` //Переменные от которых зависит секция + Condition string `yaml:"condition"` //Условие отображения секции + Actions []SectionAction `yaml:"actions"` //Действия выполняемые после перехода с секции } +// SectionField структура описывающая поле секции type SectionField struct { - Name string `yaml:"name"` - VariableName string `yaml:"variable-name"` - FieldType string `yaml:"field-type"` - FileFieldExtensions []string `yaml:"file-field-extensions"` - Description string `yaml:"description"` - Condition string `yaml:"condition"` - Options []interface{} `yaml:"options"` - SpecialValue string `yaml:"special-value"` - Compute string `yaml:"compute"` - Validate []FieldValidate `yaml:"validate"` - Parent []string `yaml:"parent"` - Variable *Variable `yaml:"-"` - Active bool `yaml:"-"` + Name string `yaml:"name"` //Имя поля + VariableName string `yaml:"variable-name"` //Имя переменной, хранящей значение поля + FieldType string `yaml:"field-type"` //Тип поля + FileFieldExtensions []string `yaml:"file-field-extensions"` //Расширения файла для поля выбора файла + Description string `yaml:"description"` //Описание поля + Condition string `yaml:"condition"` //Условие отображения поля + Options []interface{} `yaml:"options"` //Список опций + SpecialValue string `yaml:"special-value"` //Специального значения + Compute string `yaml:"compute"` //Строка вычислений значения + Validate []FieldValidate `yaml:"validate"` //Правила проверки значения + Parent []string `yaml:"parent"` //Переменные от которых зависит поле + Variable *Variable `yaml:"-"` //Указатель на переменную + Active bool `yaml:"-"` //Активно ли поле для получения значения } +// FieldValidate структура описывающая правила проверки значения поля type FieldValidate struct { - Rule string `yaml:"rule"` - Message string `yaml:"message"` + Rule string `yaml:"rule"` //Правило проверки + Message string `yaml:"message"` //Сообщение ошибки } +// SectionAction структура описывающая действия к выполнению type SectionAction struct { - Name string `yaml:"name"` - Type string `yaml:"type"` - Command string `yaml:"command"` - FileTarget string `yaml:"file-target"` - FileSource string `yaml:"file-source"` - Condition string `yaml:"condition"` - RunAs string `yaml:"run-as"` - RequirePackages []string `yaml:"require-packages"` - Error string `yaml:"error"` + Name string `yaml:"name"` //Имя действия + Type string `yaml:"type"` //Тип действия + Command string `yaml:"command"` //Команда для выполнения + FileTarget string `yaml:"file-target"` //Целевой файл редактирования + FileSource string `yaml:"file-source"` //Файл источник + Condition string `yaml:"condition"` //Условие для выполнения + RunAs string `yaml:"run-as"` //Пользователь для запуска + RequirePackages []string `yaml:"require-packages"` //Требуемые пакеты + Error string `yaml:"error"` //Ошибка выполнения }