Добавление и исправление комментариев, начало добавление валидации, сообщение прокрути viewport на форме

This commit is contained in:
XeRo 2026-01-20 17:40:21 +05:00
parent c7ff7662ba
commit e37cabadcc
14 changed files with 371 additions and 297 deletions

View file

@ -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"

View file

@ -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),
),
//проверяет доступность порта

View file

@ -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
}

View file

@ -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{} {

View file

@ -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()

View file

@ -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) //Метод отслеживания событий связанных с моделью

View file

@ -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()

View file

@ -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

View file

@ -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 {
@ -31,6 +36,7 @@ func NewSelectModel(label, value, description string, options []string) *SelectM
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

View file

@ -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,16 +192,32 @@ 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)
fieldsCmd = m.updateFields(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)
// }
//}

View file

@ -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 {

31
setup/utils/utils.go Normal file
View file

@ -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
}

View file

@ -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 //Направление прокрутки
}

View file

@ -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"` //Ошибка выполнения
}