Добавление и исправление комментариев, начало добавление валидации, сообщение прокрути 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" variable-name: "is_secure"
field-type: "check" field-type: "check"
description: "Использовать SSL" description: "Использовать SSL"
validate:
- rule: "min(1,nginx_port)"
message: "Значение слишком большое"
- name: "server_address" - name: "server_address"
variable-name: "server_address" variable-name: "server_address"
field-type: "input" field-type: "input"
@ -284,6 +287,9 @@ sections:
variable-name: "debian_root" variable-name: "debian_root"
field-type: "directory" field-type: "directory"
description: "Директория debian" description: "Директория debian"
validate:
- rule: "dir_exists(debian_root)"
message: "Папка не существует"
- name: "finale" - name: "finale"
description: "Последний этап, проверьте правильность введенных данных" description: "Последний этап, проверьте правильность введенных данных"
type: "final" type: "final"
@ -307,8 +313,8 @@ sections:
error: "Ошибка при создании файла repko.target.service" error: "Ошибка при создании файла repko.target.service"
- name: "update_repko_ini" - name: "update_repko_ini"
type: "file_template" type: "file_template"
# file-source: "/home/test/Projects/Personal/GoSetup/_public/files/donor/config.donor.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-target: "/home/test/Projects/Personal/GoSetup/_public/files/target/config.target.ini"
file-source: "http://gsg.site/_public/config.donor.ini" file-source: "http://gsg.site/_public/config.donor.ini"
file-target: "/home/test/Projects/Personal/GoSetup/_public/files/target/http.config.ini" file-target: "/home/test/Projects/Personal/GoSetup/_public/files/target/http.config.ini"
error: "Ошибка при создании файла repko.target.service" error: "Ошибка при создании файла repko.target.service"

View file

@ -2,10 +2,13 @@ package setup
import ( import (
"bubble/types" "bubble/types"
"errors"
"fmt" "fmt"
"github.com/charmbracelet/bubbles/textinput"
"github.com/expr-lang/expr" "github.com/expr-lang/expr"
"github.com/sirupsen/logrus" "github.com/sirupsen/logrus"
"io" "io"
"io/fs"
"net/http" "net/http"
"os" "os"
"os/exec" "os/exec"
@ -18,8 +21,7 @@ import (
// userRE - с начала строки, делит все по знаку двоеточия, берется первые 3 группы, пример user_name:x:1000: // userRE - с начала строки, делит все по знаку двоеточия, берется первые 3 группы, пример user_name:x:1000:
var userRE = regexp.MustCompile(`^([^:]+):[^:]*:([0-9]+):`) var userRE = regexp.MustCompile(`^([^:]+):[^:]*:([0-9]+):`)
// ParamsForCondition — собирает все текущие значения переменных приложения // ParamsForCondition получение словаря переменных с приведением типов для проверки условий
// для использования в expr-условиях (condition)
func ParamsForCondition(app *types.PackageApplication) map[string]interface{} { func ParamsForCondition(app *types.PackageApplication) map[string]interface{} {
params := make(map[string]interface{}) params := make(map[string]interface{})
@ -77,7 +79,7 @@ func ParamsForCondition(app *types.PackageApplication) map[string]interface{} {
return params return params
} }
// CheckCondition — проверяет условие visibility (condition) для секции или поля // CheckCondition проверяет равно ли переданное условие
func CheckCondition(condition string, app *types.PackageApplication) bool { func CheckCondition(condition string, app *types.PackageApplication) bool {
if condition == "" { if condition == "" {
return true return true
@ -99,7 +101,7 @@ func CheckCondition(condition string, app *types.PackageApplication) bool {
return boolResult return boolResult
} }
// ParamsForCompute — аналогично, но только по переменным (как в твоём старом коде) // ParamsForCompute получение словаря переменных без приведения типов, для вычисления
func ParamsForCompute(app *types.PackageApplication) map[string]interface{} { func ParamsForCompute(app *types.PackageApplication) map[string]interface{} {
params := make(map[string]interface{}) params := make(map[string]interface{})
@ -129,7 +131,7 @@ func ParamsForCompute(app *types.PackageApplication) map[string]interface{} {
return params return params
} }
// ComputeValue — вычисляет значение для compute-поля // ComputeValue вычисляет значение для поля compute.
func ComputeValue(computeExpr string, app *types.PackageApplication) (interface{}, error) { func ComputeValue(computeExpr string, app *types.PackageApplication) (interface{}, error) {
if computeExpr == "" { if computeExpr == "" {
return nil, nil 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 // DefineExprCustomFunctions возвращает собственные функции expr
func DefineExprCustomFunctions() []expr.Option { func DefineExprCustomFunctions() []expr.Option {
return []expr.Option{ return []expr.Option{
@ -270,8 +310,17 @@ func DefineExprCustomFunctions() []expr.Option {
return false, nil return false, nil
} }
path := fmt.Sprintf("%v", args[0]) path := fmt.Sprintf("%v", args[0])
_, err := os.Stat(path) fileInfo, err := os.Stat(path)
return err == nil, nil 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), }, new(func(string) bool),
), ),
//проверяет наличие директории в системе //проверяет наличие директории в системе
@ -282,13 +331,16 @@ func DefineExprCustomFunctions() []expr.Option {
} }
path := fmt.Sprintf("%v", args[0]) path := fmt.Sprintf("%v", args[0])
fileInfo, err := os.Stat(path) fileInfo, err := os.Stat(path)
if err != nil { if err == nil && fileInfo.IsDir() {
return nil, err
}
if fileInfo.IsDir() {
return true, nil 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), }, new(func(string) bool),
), ),
//проверяет доступность порта //проверяет доступность порта

View file

@ -2,47 +2,62 @@ package fields
import ( import (
"fmt" "fmt"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
"strconv"
) )
// CheckboxModel структура описывающая модель выбора из двух состояний да и нет
type CheckboxModel struct { type CheckboxModel struct {
Label string label string //Имя поля
description string description string //Описание поля
Checked bool checked bool //Выбранное состояние
validate textinput.ValidateFunc //Функция валидации
err error //Поле ошибки валидации
} }
func NewCheckboxModel(label, description string, checked bool) *CheckboxModel { // NewCheckboxModel создаем новую модель выбора
return &CheckboxModel{ func NewCheckboxModel(label, description string, checked bool, validator textinput.ValidateFunc) *CheckboxModel {
Label: label, checkbox := &CheckboxModel{
label: label,
description: description, description: description,
Checked: checked, validate: validator,
checked: checked,
} }
checkbox.err = validator(strconv.FormatBool(checked))
return checkbox
} }
func (m *CheckboxModel) Init() tea.Cmd { func (m *CheckboxModel) Init() tea.Cmd {
return nil return nil
} }
// Update отслеживает действия с полем
func (m *CheckboxModel) Update(msg tea.Msg) (FieldModel, tea.Cmd) { func (m *CheckboxModel) Update(msg tea.Msg) (FieldModel, tea.Cmd) {
switch msg := msg.(type) { switch msg := msg.(type) {
case tea.KeyMsg: case tea.KeyMsg:
//Отслеживает нажатия клавиш.
switch msg.String() { switch msg.String() {
case " ", "enter": case " ", "enter":
m.Checked = !m.Checked //Если был нажат enter или space, то меняем текущее значение на противоположное
m.checked = !m.checked
m.err = m.validate(strconv.FormatBool(m.checked))
} }
} }
return m, nil return m, nil
} }
func (m *CheckboxModel) View() string { func (m *CheckboxModel) View() string {
res := "" res := fmt.Sprintf("%s: ", m.label)
res += m.Label + ": " if m.checked {
if m.Checked {
res += "[x]" res += "[x]"
} else { } else {
res += "[ ]" 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) res += fmt.Sprintf("\n%s", m.description)
} }
return res return res
@ -62,9 +77,9 @@ func (m *CheckboxModel) CaptureKeys() bool {
} }
func (m *CheckboxModel) GetLabel() string { func (m *CheckboxModel) GetLabel() string {
return m.Label return m.label
} }
func (m *CheckboxModel) GetValue() interface{} { func (m *CheckboxModel) GetValue() interface{} {
return m.Checked return m.checked
} }

View file

@ -5,15 +5,17 @@ import (
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
) )
// ComputeModel структура описывающая модель вывода вычисляемых значений
type ComputeModel struct { type ComputeModel struct {
Label string label string //Имя поля
description string description string //Описание поля
value string value string //Значение поля
} }
// NewComputeModel создаем новую модель вывода вычисляемых значений
func NewComputeModel(label, value, description string) *ComputeModel { func NewComputeModel(label, value, description string) *ComputeModel {
return &ComputeModel{ return &ComputeModel{
Label: label, label: label,
description: description, description: description,
value: value, value: value,
} }
@ -23,12 +25,13 @@ func (m *ComputeModel) Init() tea.Cmd {
return nil return nil
} }
// Update отслеживает действия с полем. Так как поле нужно только для вывода, никаких действий не выполняем.
func (m *ComputeModel) Update(msg tea.Msg) (FieldModel, tea.Cmd) { func (m *ComputeModel) Update(msg tea.Msg) (FieldModel, tea.Cmd) {
return m, nil return m, nil
} }
func (m *ComputeModel) View() string { 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 != "" { if m.description != "" {
res += fmt.Sprintf("\n%s", m.description) res += fmt.Sprintf("\n%s", m.description)
} }
@ -46,7 +49,7 @@ func (m *ComputeModel) CaptureKeys() bool {
} }
func (m *ComputeModel) GetLabel() string { func (m *ComputeModel) GetLabel() string {
return m.Label return m.label
} }
func (m *ComputeModel) GetValue() interface{} { func (m *ComputeModel) GetValue() interface{} {

View file

@ -2,6 +2,7 @@ package fields
import ( import (
"bubble/setup/ui" "bubble/setup/ui"
"bubble/setup/utils"
"fmt" "fmt"
"github.com/charmbracelet/bubbles/textinput" "github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
@ -11,20 +12,23 @@ import (
"strings" "strings"
) )
// DirSelectModel структура описывающая модель поля выбора директории
type DirSelectModel struct { type DirSelectModel struct {
input textinput.Model input textinput.Model //Готовый компонент input для ввода данных
label string label string //Имя поля
description string description string //Описание поля
focus int focus int //Фокус для управления опциями
open bool open bool //Открыт ли список
dir string dir string //Имя выбранной директории
currentDirectory string currentDirectory string //Директория текущего файла
options []os.FileInfo options []os.FileInfo //Элементы выбора файлов
optionsCount int optionsCount int //Количество элементов к показу
} }
func NewDirSelectModel(label, dir, description string) *DirSelectModel { // NewDirSelectModel создает новую модель поля выбора директории
func NewDirSelectModel(label, dir, description string, validator textinput.ValidateFunc) *DirSelectModel {
inputModel := textinput.New() inputModel := textinput.New()
inputModel.Validate = validator
inputModel.SetValue(dir) inputModel.SetValue(dir)
return &DirSelectModel{ return &DirSelectModel{
input: inputModel, input: inputModel,
@ -41,27 +45,37 @@ func (m *DirSelectModel) Init() tea.Cmd {
return textinput.Blink return textinput.Blink
} }
// Update отслеживает действия с полями
func (m *DirSelectModel) Update(msg tea.Msg) (FieldModel, tea.Cmd) { func (m *DirSelectModel) Update(msg tea.Msg) (FieldModel, tea.Cmd) {
var cmds []tea.Cmd var cmds []tea.Cmd
switch msg := msg.(type) { switch msg := msg.(type) {
case tea.KeyMsg: case tea.KeyMsg:
//Отслеживает нажатия клавиш.
//Если список открыт, то отслеживаем клавиши отдельно
if m.open { if m.open {
switch msg.String() { switch msg.String() {
case "esc": case "esc":
//Закрываем список
m.open = false m.open = false
case "up": case "up":
//Уменьшаем фокус, поднимаем его
m.focus-- m.focus--
if m.focus < 0 { if m.focus < 0 {
m.focus = len(m.options) - 1 m.focus = len(m.options) - 1
} }
case "down": case "down":
//Увеличиваем фокус, опуская его
m.focus++ m.focus++
if m.focus == len(m.options) { if m.focus == len(m.options) {
m.focus = 0 m.focus = 0
} }
case "enter": 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 return m, nil
} }
file := m.options[m.focus] file := m.options[m.focus]
@ -88,6 +102,8 @@ func (m *DirSelectModel) Update(msg tea.Msg) (FieldModel, tea.Cmd) {
} }
} }
} else { } else {
//Если список не открыт и был нажат enter, открываем его и рисуем опции списка.
//Иначе просто передаем сообщения нажатия клавиш в input.
switch msg.String() { switch msg.String() {
case "enter": case "enter":
m.open = true m.open = true
@ -115,6 +131,7 @@ func (m *DirSelectModel) Update(msg tea.Msg) (FieldModel, tea.Cmd) {
} }
} }
default: default:
//Все прочие виды сообщений передаем в input если список закрыт
if !m.open { if !m.open {
var cmd tea.Cmd var cmd tea.Cmd
m.input, cmd = m.input.Update(msg) 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 { func (m *DirSelectModel) View() string {
res := fmt.Sprintf("%s: %s", m.label, m.input.View()) 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) res += fmt.Sprintf("\n%s", m.description)
} }
if m.open && len(m.options) > 0 { if m.open && len(m.options) > 0 {
visibleCount := m.optionsCount start, end := utils.CalculateSelectOptionsIndex(m.focus, m.optionsCount, len(m.options))
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
}
}
for i := start; i < end; i++ { for i := start; i < end; i++ {
file := m.options[i] file := m.options[i]
name := file.Name() name := file.Name()
@ -188,21 +191,26 @@ func (m *DirSelectModel) GetValue() interface{} {
return m.dir return m.dir
} }
// RenderOptions заполняем список опций на основе директорий
func (m *DirSelectModel) RenderOptions() { func (m *DirSelectModel) RenderOptions() {
//Читаем файлы к текущей директории
filesInDir, err := os.ReadDir(m.currentDirectory) filesInDir, err := os.ReadDir(m.currentDirectory)
if err != nil { if err != nil {
logrus.Errorf("Ошибка чтения директории: %v", err) logrus.Errorf("Ошибка чтения директории: %v", err)
return return
} }
m.options = nil m.options = nil
//Если это не корень файловой системы, добавляем переход на родительскую директорию
var allOptions []os.FileInfo var allOptions []os.FileInfo
if m.currentDirectory != "/" && filepath.Clean(m.currentDirectory) != "/" { if m.currentDirectory != "/" && filepath.Clean(m.currentDirectory) != "/" {
allOptions = append(allOptions, virtualDirInfo{name: ".."}) allOptions = append(allOptions, virtualDirInfo{name: ".."})
} }
//Заполняем прочие опции
for _, entry := range filesInDir { for _, entry := range filesInDir {
info, _ := entry.Info() info, _ := entry.Info()
allOptions = append(allOptions, info) allOptions = append(allOptions, info)
} }
//Заполняем фильтр для опций на основе значения поля ввода
filter := "" filter := ""
if m.open { if m.open {
inputValue := strings.TrimSpace(m.input.Value()) inputValue := strings.TrimSpace(m.input.Value())
@ -213,6 +221,7 @@ func (m *DirSelectModel) RenderOptions() {
} }
} }
} }
//Чистим опции и заполняем их правильными значениями
m.options = m.options[:0] m.options = m.options[:0]
for _, opt := range allOptions { for _, opt := range allOptions {
name := opt.Name() name := opt.Name()

View file

@ -2,6 +2,7 @@ package fields
import tea "github.com/charmbracelet/bubbletea" import tea "github.com/charmbracelet/bubbletea"
// FieldModel интерфейс работы с полями формы
type FieldModel interface { type FieldModel interface {
Init() tea.Cmd //Метод инициализации поля Init() tea.Cmd //Метод инициализации поля
Update(msg tea.Msg) (FieldModel, tea.Cmd) //Метод отслеживания событий связанных с моделью Update(msg tea.Msg) (FieldModel, tea.Cmd) //Метод отслеживания событий связанных с моделью

View file

@ -2,6 +2,7 @@ package fields
import ( import (
"bubble/setup/ui" "bubble/setup/ui"
"bubble/setup/utils"
"fmt" "fmt"
"github.com/charmbracelet/bubbles/textinput" "github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
@ -28,8 +29,9 @@ type FileSelectModel struct {
} }
// NewFileSelectModel создаем новую модель поля выбора файла // 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 := textinput.New()
inputModel.Validate = validator
inputModel.SetValue(file) inputModel.SetValue(file)
return &FileSelectModel{ return &FileSelectModel{
input: inputModel, 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 return m, nil
} }
file := m.options[m.focus] file := m.options[m.focus]
@ -112,7 +114,7 @@ func (m *FileSelectModel) Update(msg tea.Msg) (FieldModel, tea.Cmd) {
} }
} }
} else { } else {
//Если список не открыт и был нажат enter открываем его и рисуем опции списка. //Если список не открыт и был нажат enter, открываем его и рисуем опции списка.
//Иначе просто передаем сообщение нажатия клавиш в input. //Иначе просто передаем сообщение нажатия клавиш в input.
switch msg.String() { switch msg.String() {
case "enter": case "enter":
@ -142,27 +144,13 @@ func (m *FileSelectModel) Update(msg tea.Msg) (FieldModel, tea.Cmd) {
func (m *FileSelectModel) View() string { func (m *FileSelectModel) View() string {
res := fmt.Sprintf("%s: %s", m.label, m.input.View()) 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) res += fmt.Sprintf("\n%s", m.description)
} }
if m.open && len(m.options) > 0 { if m.open && len(m.options) > 0 {
visibleCount := m.optionsCount //количество отображаемых start, end := utils.CalculateSelectOptionsIndex(m.focus, m.optionsCount, len(m.options))
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
}
}
for i := start; i < end; i++ { for i := start; i < end; i++ {
file := m.options[i] file := m.options[i]
name := file.Name() name := file.Name()

View file

@ -14,9 +14,13 @@ type InputModel struct {
input textinput.Model input textinput.Model
} }
type FieldValidator func(value any) error
// NewInputModel создает новую модель поля ввода данных // NewInputModel создает новую модель поля ввода данных
func NewInputModel(label, value, description string) *InputModel { func NewInputModel(label, value, description string, validator textinput.ValidateFunc) *InputModel {
t := textinput.New() t := textinput.New()
t.Validate = validator
t.SetValue(value) t.SetValue(value)
t.Cursor.SetMode(cursor.CursorBlink) t.Cursor.SetMode(cursor.CursorBlink)
return &InputModel{ return &InputModel{
@ -39,7 +43,9 @@ func (m *InputModel) Update(msg tea.Msg) (FieldModel, tea.Cmd) {
func (m *InputModel) View() string { func (m *InputModel) View() string {
res := fmt.Sprintf("%s: %v", m.label, m.input.View()) 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) res += fmt.Sprintf("\n%s", m.description)
} }
return res return res

View file

@ -1,21 +1,26 @@
package fields package fields
import ( import (
"bubble/setup/utils"
"fmt" "fmt"
"github.com/charmbracelet/bubbles/textinput" "github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
) )
// SelectModel структура описывающая модель поля выбора опции
type SelectModel struct { type SelectModel struct {
label string label string //Имя поля
description string description string //Описание пол
options []string options []string //Элементы выбора
focus int focus int //Фокус для управления опциями
open bool open bool //Открыт ли список
chosen string chosen string //Выбранный элемент
optionsCount int //Количество элементов к показу
} }
// NewSelectModel создаем новую поля выбора опции
func NewSelectModel(label, value, description string, options []string) *SelectModel { func NewSelectModel(label, value, description string, options []string) *SelectModel {
//Проверка, что указанный по умолчанию элемент есть в списке опции
found := false found := false
for i := range options { for i := range options {
if options[i] == value { if options[i] == value {
@ -31,6 +36,7 @@ func NewSelectModel(label, value, description string, options []string) *SelectM
description: description, description: description,
focus: len(options), focus: len(options),
chosen: value, chosen: value,
optionsCount: 10,
} }
} }
@ -42,30 +48,37 @@ func (m *SelectModel) Update(msg tea.Msg) (FieldModel, tea.Cmd) {
var cmd tea.Cmd var cmd tea.Cmd
switch msg := msg.(type) { switch msg := msg.(type) {
case tea.KeyMsg: case tea.KeyMsg:
//Отслеживаем нажатия клавиш.
//Если список открыт, то отслеживаем клавиши отдельно
if m.open { if m.open {
switch msg.String() { switch msg.String() {
case "esc": case "esc":
//Закрываем список
m.open = false m.open = false
case "up": case "up":
//Уменьшаем фокус, поднимаем его
m.focus-- m.focus--
if m.focus < 0 { if m.focus < 0 {
m.focus = len(m.options) - 1 m.focus = len(m.options) - 1
} }
case "down": case "down":
//Увеличиваем фокус, опускаем его
m.focus++ m.focus++
if m.focus == len(m.options) { if m.focus == len(m.options) {
m.focus = 0 m.focus = 0
} }
case "enter": 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 return m, nil
} }
m.chosen = m.options[m.focus] m.chosen = m.options[m.focus]
m.open = false m.open = false
m.focus = len(m.options) m.focus = 0
return m, nil return m, nil
} }
} else { } else {
//Если список не открыт и был нажат enter, открываем его и устанавливаем фокус на основе опций.
switch msg.String() { switch msg.String() {
case "enter": case "enter":
m.open = true m.open = true
@ -91,12 +104,16 @@ func (m *SelectModel) View() string {
res += fmt.Sprintf("\n%s", m.description) res += fmt.Sprintf("\n%s", m.description)
} }
if m.open && len(m.options) > 0 { 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 { if m.focus == i {
res += "\n\t-> " + m.options[i] prefix += "-> "
} else { } else {
res += "\n\t " + m.options[i] prefix += " "
} }
res += prefix + option
} }
} }
return res return res

View file

@ -55,7 +55,8 @@ func NewFormModel(application *types.PackageApplication, sectionIdx, focus, widt
} else { } else {
checked = sectionField.Variable.Value == "true" 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": case "compute":
value, err := ComputeValue(sectionField.Compute, application) value, err := ComputeValue(sectionField.Compute, application)
if err != nil { 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)) fieldModels = append(fieldModels, fields.NewComputeModel(sectionField.Name, fmt.Sprintf("%v", value), sectionField.Description))
//compute расчет //compute расчет
case "file": 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": 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: 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 события, который обновляет форму в ТЕКУЩЕМ состоянии) //Устанавливаем фокус (фокус может быть не равен нулю, если новая форма сформирована после Sugar события, который обновляет форму в ТЕКУЩЕМ состоянии)
@ -100,7 +104,6 @@ func (m *FormModel) Init() tea.Cmd {
// Update отслеживает действия с формой // Update отслеживает действия с формой
func (m *FormModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { func (m *FormModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
//todo сообщение для переноса viewport вверх или внизе
switch msg := msg.(type) { switch msg := msg.(type) {
case tea.WindowSizeMsg: case tea.WindowSizeMsg:
//Отслеживаем и записываем новые размеры окна, обновляем рендер about //Отслеживаем и записываем новые размеры окна, обновляем рендер about
@ -189,16 +192,32 @@ func (m *FormModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
} }
//Проверка правильности фокуса, чтобы не выйти за границы массива полей формы, необходимо проверить новое значение фокуса. //Проверка правильности фокуса, чтобы не выйти за границы массива полей формы, необходимо проверить новое значение фокуса.
//Если фокус стал больше чем количество полей формы, значит он вышел за пределы всей формы и находится уже даже ниже кнопки, //Если фокус стал больше чем количество полей формы, значит он вышел за пределы всей формы и находится уже даже ниже кнопки,
//переводим его на первую позицию, то есть на 0. //переводим его на первую позицию, то есть на 0 и перемещаем видимую область наверх.
//Если фокус стал меньше 0, значит он вышел за пределы формы и находится выше первого поля формы, //Если фокус стал меньше 0, значит он вышел за пределы формы и находится выше первого поля формы,
//переводим его на последнюю позицию, то есть на кнопку переключения секции, равную положение которой = количество полей на форме. //переводим его на последнюю позицию, то есть на кнопку переключения секции, равную положение которой = количество полей на форме и перемещаем видимую область вниз.
if m.focus == len(m.FieldModels)+1 { if m.focus == len(m.FieldModels)+1 {
m.focus = 0 m.focus = 0
return m, func() tea.Msg {
return types.ViewportScrollMsg{
Direction: "up",
}
}
} else if m.focus < 0 { } else if m.focus < 0 {
m.focus = len(m.FieldModels) 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 var viewportCmd, fieldsCmd tea.Cmd
m.viewport, viewportCmd = m.viewport.Update(msg) m.viewport, viewportCmd = m.viewport.Update(msg)
fieldsCmd = m.updateFields(msg) fieldsCmd = m.updateFields(msg)
@ -225,11 +244,9 @@ func (m *FormModel) View() string {
func (m *FormModel) initViewport(width, height int) { func (m *FormModel) initViewport(width, height int) {
_, _, contentWidth, contentHeight := ui.ContentSizes(width, height) _, _, contentWidth, contentHeight := ui.ContentSizes(width, height)
contentAreaWidth := contentWidth - 2*1 m.viewport = viewport.New(contentWidth, contentHeight)
contentAreaHeight := contentHeight - 2*1
m.viewport = viewport.New(contentWidth, contentAreaHeight)
m.viewport.SetHorizontalStep(1) m.viewport.SetHorizontalStep(1)
m.rebuildContent(contentAreaWidth) m.rebuildContent(contentWidth)
} }
func (m *FormModel) rebuildContent(width int) { 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) fieldStroke := wordwrap.String(field.View(), width)
//Если фокус равен текущему полю, то выделяем его. //Если фокус равен текущему полю, то выделяем его.
var finalStroke string
if i == focus { if i == focus {
sb.WriteString(ui.FormFieldFocused.Render(fieldStroke) + "\n\n") finalStroke = ui.FormFieldFocused.Render(fieldStroke)
} else { } 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() return sb.String()
} }
@ -269,12 +293,13 @@ func simpleForm(fields []fields.FieldModel, focus, width int) string {
// simpleAboutRender формирует единую строку, описывающую настраиваемое приложение. // simpleAboutRender формирует единую строку, описывающую настраиваемое приложение.
func simpleAboutRender(paragraphs []string, width int) string { func simpleAboutRender(paragraphs []string, width int) string {
var sb strings.Builder var sb strings.Builder
for _, p := range paragraphs { for i, p := range paragraphs {
p = strings.TrimSpace(p) p = strings.TrimSpace(p)
if p == "" { if p == "" {
sb.WriteString("\n") sb.WriteString("\n")
continue continue
} }
var finalStroke, strokeType string
switch { switch {
case strings.HasPrefix(p, "# "): case strings.HasPrefix(p, "# "):
// Заголовок 1 уровня // Заголовок 1 уровня
@ -283,32 +308,49 @@ func simpleAboutRender(paragraphs []string, width int) string {
//sb.WriteString(ui.AboutTitle.Render(h1Offset+p[2:]) + "\n\n") //sb.WriteString(ui.AboutTitle.Render(h1Offset+p[2:]) + "\n\n")
text := strings.TrimPrefix(p, "# ") text := strings.TrimPrefix(p, "# ")
wrapped := wordwrap.String(text, width) wrapped := wordwrap.String(text, width)
sb.WriteString(ui.AboutTitle.Render(wrapped) + "\n\n") finalStroke = ui.AboutTitle.Render(wrapped)
strokeType = "header1"
case strings.HasPrefix(p, "## "): case strings.HasPrefix(p, "## "):
// Заголовок 2 уровня // Заголовок 2 уровня
text := strings.TrimPrefix(p, "## ") text := strings.TrimPrefix(p, "## ")
wrapped := wordwrap.String(text, width) wrapped := wordwrap.String(text, width)
sb.WriteString(ui.AboutH2.Render(wrapped) + "\n\n") finalStroke = ui.AboutH2.Render(wrapped)
strokeType = "header2"
case strings.HasPrefix(p, "### "): case strings.HasPrefix(p, "### "):
// Заголовок 3 уровня // Заголовок 3 уровня
text := strings.TrimPrefix(p, "### ") text := strings.TrimPrefix(p, "### ")
wrapped := wordwrap.String(text, width) 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, "* "): case strings.HasPrefix(p, "- "), strings.HasPrefix(p, "* "):
// Список простой // Список простой
text := strings.TrimLeft(p[2:], " ") text := strings.TrimLeft(p[2:], " ")
wrapped := wordwrap.String(text, width) 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, "> "): case strings.HasPrefix(p, "> "):
// Цитаты // Цитаты
text := strings.TrimPrefix(p, "> ") text := strings.TrimPrefix(p, "> ")
wrapped := wordwrap.String(text, width) 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: default:
// Обычный текст // Обычный текст
wrapped := wordwrap.String(p, width) 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() return sb.String()
} }
@ -350,7 +392,6 @@ func (m *FormModel) updateFields(msg tea.Msg) tea.Cmd {
return types.SugarMsg{ return types.SugarMsg{
VariableName: sectionField.VariableName, VariableName: sectionField.VariableName,
FieldName: sectionField.Name, FieldName: sectionField.Name,
Compute: sectionField.Compute,
Focus: m.focus, Focus: m.focus,
} }
}) })
@ -367,116 +408,3 @@ func (m *FormModel) updateFields(msg tea.Msg) tea.Cmd {
//Объединяем весь массив команду в одну //Объединяем весь массив команду в одну
return tea.Batch(cmds...) 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 { func IsParent(variableName string, sections []types.Section) bool {
for _, section := range sections { for _, section := range sections {
for _, parent := range section.Parent { 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" import tea "github.com/charmbracelet/bubbletea"
// SelectedAppMsg сообщение выбора приложения из списка приложений
type SelectedAppMsg struct { type SelectedAppMsg struct {
App PackageApplication App PackageApplication //Объект выбранного приложения
} }
// BackToListMsg сообщение перехода к списку приложений
type BackToListMsg struct{} type BackToListMsg struct{}
// NextSectionMsg сообщение перехода на следующую секцию
type NextSectionMsg struct{} type NextSectionMsg struct{}
// SugarMsg сообщение о возможной необходимости обновить форму
type SugarMsg struct { type SugarMsg struct {
VariableName string VariableName string //Имя переменной присвоенной измененному полю
FieldName string FieldName string //Имя измененного поля
Compute string Focus int //Старый фокус формы
Focus int
} }
// ShowModalMsg сообщение о показе модального окна
type ShowModalMsg struct { type ShowModalMsg struct {
Type ModalType Type ModalType //Тип модального окна
Title string Title string //Заголовок модального окна
Message string Message string //Сообщение модального окна
ModalButtons []ModalButton ModalButtons []ModalButton //Кнопки модального окна
OnResult func(result any) tea.Cmd OnResult func(result any) tea.Cmd //Функция для выполнения
} }
// ModalButton структура кнопки модального окна
type ModalButton struct { type ModalButton struct {
Text string Text string //Текст кнопки
Value any Value any //Значение кнопки
} }
// CloseModalMsg сообщение о закрытии модального окна
type CloseModalMsg struct{} type CloseModalMsg struct{}
// ViewportScrollMsg сообщение прокрутки видимой области формы
type ViewportScrollMsg struct {
Direction string //Направление прокрутки
}

View file

@ -1,70 +1,77 @@
package types package types
// PackageApplication структура описывающая настраиваемое приложение
type PackageApplication struct { type PackageApplication struct {
Name string `yaml:"name"` Name string `yaml:"name"` //Имя приложения
Version string `yaml:"version"` Version string `yaml:"version"` //Версия приложения
Description string `yaml:"description"` Description string `yaml:"description"` //Описание приложения
Icon string `yaml:"icon"` Icon string `yaml:"icon"` //Иконка приложения
Type string `yaml:"type"` Type string `yaml:"type"` //Тип приложения
ConfFiles []ConfFile `yaml:"conf-files"` ConfFiles []ConfFile `yaml:"conf-files"` //Файлы конфигурации
Variables []Variable `yaml:"variables"` Variables []Variable `yaml:"variables"` //Переменные настройки
Sections []Section `yaml:"sections"` Sections []Section `yaml:"sections"` //Секции настройки
Changed bool `yaml:"-"` Changed bool `yaml:"-"` //Вносились ли изменения
} }
// ConfFile структура описывающая используемый приложением конфигурационный файл
type ConfFile struct { type ConfFile struct {
File string `yaml:"file"` File string `yaml:"file"` //Путь файла
Type string `yaml:"type"` Type string `yaml:"type"` //Тип файла
Name string `yaml:"name"` Name string `yaml:"name"` //Имя файла
} }
// Variable структура описывающая переменную для настройки приложения
type Variable struct { type Variable struct {
Name string `yaml:"name"` Name string `yaml:"name"` //Имя переменной
Type string `yaml:"type"` Type string `yaml:"type"` //Тип значения переменной
Default string `yaml:"default"` Default string `yaml:"default"` //Значение по умолчанию
Value interface{} `yaml:"-"` Value interface{} `yaml:"-"` //Используемое значение
} }
// Section структура описывающая секцию настройки приложения
type Section struct { type Section struct {
Name string `yaml:"name"` Name string `yaml:"name"` //Имя секции
Type string `yaml:"type"` Type string `yaml:"type"` //Тип секции
Description string `yaml:"description"` Description string `yaml:"description"` //Описание секции
Fields []SectionField `yaml:"fields"` Fields []SectionField `yaml:"fields"` //Поля секции
Paragraphs []string `yaml:"paragraphs"` Paragraphs []string `yaml:"paragraphs"` //Параграфы для описания приложения
Parent []string `yaml:"parent"` Parent []string `yaml:"parent"` //Переменные от которых зависит секция
Condition string `yaml:"condition"` Condition string `yaml:"condition"` //Условие отображения секции
Actions []SectionAction `yaml:"actions"` Actions []SectionAction `yaml:"actions"` //Действия выполняемые после перехода с секции
} }
// SectionField структура описывающая поле секции
type SectionField struct { type SectionField struct {
Name string `yaml:"name"` Name string `yaml:"name"` //Имя поля
VariableName string `yaml:"variable-name"` VariableName string `yaml:"variable-name"` //Имя переменной, хранящей значение поля
FieldType string `yaml:"field-type"` FieldType string `yaml:"field-type"` //Тип поля
FileFieldExtensions []string `yaml:"file-field-extensions"` FileFieldExtensions []string `yaml:"file-field-extensions"` //Расширения файла для поля выбора файла
Description string `yaml:"description"` Description string `yaml:"description"` //Описание поля
Condition string `yaml:"condition"` Condition string `yaml:"condition"` //Условие отображения поля
Options []interface{} `yaml:"options"` Options []interface{} `yaml:"options"` //Список опций
SpecialValue string `yaml:"special-value"` SpecialValue string `yaml:"special-value"` //Специального значения
Compute string `yaml:"compute"` Compute string `yaml:"compute"` //Строка вычислений значения
Validate []FieldValidate `yaml:"validate"` Validate []FieldValidate `yaml:"validate"` //Правила проверки значения
Parent []string `yaml:"parent"` Parent []string `yaml:"parent"` //Переменные от которых зависит поле
Variable *Variable `yaml:"-"` Variable *Variable `yaml:"-"` //Указатель на переменную
Active bool `yaml:"-"` Active bool `yaml:"-"` //Активно ли поле для получения значения
} }
// FieldValidate структура описывающая правила проверки значения поля
type FieldValidate struct { type FieldValidate struct {
Rule string `yaml:"rule"` Rule string `yaml:"rule"` //Правило проверки
Message string `yaml:"message"` Message string `yaml:"message"` //Сообщение ошибки
} }
// SectionAction структура описывающая действия к выполнению
type SectionAction struct { type SectionAction struct {
Name string `yaml:"name"` Name string `yaml:"name"` //Имя действия
Type string `yaml:"type"` Type string `yaml:"type"` //Тип действия
Command string `yaml:"command"` Command string `yaml:"command"` //Команда для выполнения
FileTarget string `yaml:"file-target"` FileTarget string `yaml:"file-target"` //Целевой файл редактирования
FileSource string `yaml:"file-source"` FileSource string `yaml:"file-source"` //Файл источник
Condition string `yaml:"condition"` Condition string `yaml:"condition"` //Условие для выполнения
RunAs string `yaml:"run-as"` RunAs string `yaml:"run-as"` //Пользователь для запуска
RequirePackages []string `yaml:"require-packages"` RequirePackages []string `yaml:"require-packages"` //Требуемые пакеты
Error string `yaml:"error"` Error string `yaml:"error"` //Ошибка выполнения
} }