2025-12-26 17:45:37 +05:00
package setup
import (
"bubble/setup/fields"
"bubble/setup/ui"
"bubble/types"
"fmt"
2026-01-16 17:44:03 +05:00
"github.com/charmbracelet/bubbles/viewport"
2025-12-26 17:45:37 +05:00
tea "github.com/charmbracelet/bubbletea"
2025-12-29 17:46:48 +05:00
"github.com/charmbracelet/lipgloss"
"github.com/muesli/reflow/wordwrap"
2025-12-26 17:45:37 +05:00
"github.com/sirupsen/logrus"
"strings"
)
2026-01-15 17:55:52 +05:00
// FormModel структура описывающая модель отрисовки формы настройки
2025-12-26 17:45:37 +05:00
type FormModel struct {
2026-01-15 17:55:52 +05:00
CurrentSection * types . Section //Указатель секции с которой надо работать
FieldModels [ ] fields . FieldModel //Массив моделей полей
focus int //Фокус для отслеживания с каким объектом надо работать
2026-01-16 17:44:03 +05:00
renderedContent string //Строка хранения информации о приложении // todo поместить во viewport
viewport viewport . Model //Объект куда помещена информация о приложении для возможности прокручивать её
2026-01-15 17:55:52 +05:00
width int //Ширина окна
height int //Высота окна
2025-12-26 17:45:37 +05:00
}
2026-01-15 17:55:52 +05:00
// NewFormModel создает новую модель формы на основе данных настраиваемого приложения
2025-12-29 17:46:48 +05:00
func NewFormModel ( application * types . PackageApplication , sectionIdx , focus , width , height int ) * FormModel {
2026-01-15 17:55:52 +05:00
//Получаем секцию которую надо отрисовать
2025-12-26 17:45:37 +05:00
currentSection := & application . Sections [ sectionIdx ]
2026-01-15 17:55:52 +05:00
//Заполняем массив моделей полей с учетом условий
2025-12-26 17:45:37 +05:00
var fieldModels [ ] fields . FieldModel
for _ , sectionField := range currentSection . Fields {
if ! CheckCondition ( sectionField . Condition , application ) {
continue
}
switch sectionField . FieldType {
case "select" :
var options [ ] string
for j := range sectionField . Options {
options = append ( options , fmt . Sprintf ( "%v" , sectionField . Options [ j ] ) )
}
if sectionField . SpecialValue != "" {
specialOptions := SpecialValueHandle ( sectionField . SpecialValue )
if strSpecial , ok := specialOptions . ( [ ] string ) ; ok {
options = strSpecial
}
}
selected := fmt . Sprintf ( "%v" , sectionField . Variable . Value )
fieldModels = append ( fieldModels , fields . NewSelectModel ( sectionField . Name , selected , options ) )
case "check" :
checked := false
if value , ok := sectionField . Variable . Value . ( bool ) ; ok {
checked = value
} else {
checked = sectionField . Variable . Value == "true"
}
fieldModels = append ( fieldModels , fields . NewCheckboxModel ( sectionField . Name , checked ) )
case "compute" :
value , err := ComputeValue ( sectionField . Compute , application )
if err != nil {
value = fmt . Sprintf ( "Ошибка вычисления: %v" , err )
}
fieldModels = append ( fieldModels , fields . NewComputeModel ( sectionField . Name , fmt . Sprintf ( "%v" , value ) ) )
//compute расчет
case "file" :
fieldModels = append ( fieldModels , fields . NewFileSelectModel ( sectionField . Name , fmt . Sprintf ( "%v" , sectionField . Variable . Value ) , sectionField . FileFieldExtensions ) )
case "directory" :
fieldModels = append ( fieldModels , fields . NewDirSelectModel ( sectionField . Name , fmt . Sprintf ( "%v" , sectionField . Variable . Value ) ) )
default :
fieldModels = append ( fieldModels , fields . NewInputModel ( sectionField . Name , fmt . Sprintf ( "%v" , sectionField . Variable . Value ) ) )
}
}
2026-01-15 17:55:52 +05:00
//Устанавливаем фокус (фокус может быть не равен нулю, если новая форма сформирована после Sugar события, который обновляет форму в ТЕКУЩЕМ состоянии)
2025-12-26 17:45:37 +05:00
if len ( fieldModels ) > focus {
fieldModels [ focus ] . Focus ( )
}
m := & FormModel {
FieldModels : fieldModels ,
focus : focus ,
CurrentSection : currentSection ,
2025-12-29 17:46:48 +05:00
width : width ,
height : height ,
2025-12-26 17:45:37 +05:00
}
2026-01-16 17:44:03 +05:00
////Формируем описание приложения если секция установлена About
//if currentSection.Type == types.SectionAbout {
// m.renderedContent = simpleAboutRender(currentSection.Paragraphs, width)
// m.focus = 0
//}
if width > 0 && height > 0 {
m . initViewport ( width , height )
2025-12-26 17:45:37 +05:00
}
return m
}
func ( m * FormModel ) Init ( ) tea . Cmd {
return nil
}
2026-01-15 17:55:52 +05:00
// Update отслеживает действия с формой
2025-12-26 17:45:37 +05:00
func ( m * FormModel ) Update ( msg tea . Msg ) ( tea . Model , tea . Cmd ) {
2026-01-14 18:08:43 +05:00
2025-12-26 17:45:37 +05:00
switch msg := msg . ( type ) {
2025-12-29 17:46:48 +05:00
case tea . WindowSizeMsg :
2026-01-15 17:55:52 +05:00
//Отслеживаем и записываем новые размеры окна, обновляем рендер about
2025-12-29 17:46:48 +05:00
m . width = msg . Width
m . height = msg . Height
2026-01-16 17:44:03 +05:00
m . initViewport ( m . width , m . height )
//if m.CurrentSection.Type == types.SectionAbout {
// m.renderedContent = simpleAboutRender(m.CurrentSection.Paragraphs, m.width)
//}
2025-12-29 17:46:48 +05:00
return m , nil
2025-12-26 17:45:37 +05:00
case tea . KeyMsg :
2026-01-15 17:55:52 +05:00
//Отслеживаем нажатие клавиш формы
2025-12-26 17:45:37 +05:00
switch msg . String ( ) {
case "esc" :
2026-01-15 17:55:52 +05:00
//Обработчик клавиши escape на форме нужен для закрытия полей поддерживающих открытие.
//Обходим все доступные в текущей форме поля и закрываем те, что были открыты.
//Проверять на фокус не нужно, так как не предполагается, что у нас может быть открыто сразу несколько полей.
//Поэтому просто возвращаем команду по возврату сообщение о б обновлении полей.
2025-12-29 17:46:48 +05:00
for _ , field := range m . FieldModels {
2026-01-15 17:55:52 +05:00
//проверяем открыто ли поле
2025-12-29 17:46:48 +05:00
if field . CaptureKeys ( ) {
2026-01-15 17:55:52 +05:00
//обновляем поля если оно открыто
2025-12-29 17:46:48 +05:00
return m , m . updateFields ( msg )
}
}
2026-01-15 17:55:52 +05:00
//Если открытых полей не было найдено, значит escape была нажата для закрытия настройки,
//возвращаем команду по возврату сообщения о б открытии модального окна
2025-12-29 17:46:48 +05:00
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
} ,
}
}
2025-12-26 17:45:37 +05:00
case "enter" :
2026-01-15 17:55:52 +05:00
//Обработчик клавиши enter на форме нужен для перехода на следующую секцию.
//Проверяем где сейчас находится фокус, если е г о позиция равняется количеству полей,
//значит он вышел за пределы формы и находится на кнопке next section, что ставится последней.
//После этого мы сбрасываем фокус и возвращаем команду по возврату сообщения о переходе на следующую секцию
2025-12-26 17:45:37 +05:00
if len ( m . FieldModels ) == m . focus {
2025-12-29 17:46:48 +05:00
m . focus = 0
2025-12-26 17:45:37 +05:00
return m , func ( ) tea . Msg {
return types . NextSectionMsg { }
}
}
2026-01-15 17:55:52 +05:00
case "tab" , "shift+tab" , "down" , "up" :
//Обработчик клавиш tab, shift+tab, down, up на форме нужен для перехода между объектами формы.
//Получаем точное имя клавиши нажатия, после проверяем где сейчас находится фокус.
2026-01-16 17:44:03 +05:00
//Если фокус меньше количества полей, то значит фокус сейчас на поле формы.
// Подробнее. У нас фокус в двух состояниях так сказать,
// фокус равен количество полей, значит он на кнопке следующей секции,
// если меньше количества полей или равен 0, значит фокус на поле формы.
// Если у нас нет полей на форме (секция About), значит количество полей равно 0, а фокус по умолчанию устанавливается 0.
// Чтобы m.FieldModels[m.focus] вызвало ошибку "индекс вне массива",
// нужно чтобы в условие m.focus < len(m.FieldModels) попал фокус меньше 0 (-1,-2 и тд).
// Чтобы этого избежать, есть проверка, которая устанавливает m.focus на последнюю позицию (количество полей на форме).
// Чтобы эта проверка дала сбой, нужно после неё прописать изменение фокуса формы на отрицательный принудительно, так как такое не может произойти в текущем коде.
2026-01-15 17:55:52 +05:00
//Получаем имя поля (так как это старый фокус, то и поле на котором мы находимся это предыдущее поле, отсюда и название переменной),
//Если поле не открыто, то мы можем перейти на следующее поле, увеличив или уменьшив фокус.
//Если фокус равен количеству полей, значит он находится на кнопке переключения секций. Увеличиваем или уменьшаем фокус.
2025-12-26 17:45:37 +05:00
key := msg . String ( )
2026-01-16 17:44:03 +05:00
if m . focus < len ( m . FieldModels ) {
2026-01-15 17:55:52 +05:00
prevField := m . FieldModels [ m . focus ]
if ! prevField . CaptureKeys ( ) {
2025-12-26 17:45:37 +05:00
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 --
}
}
2026-01-15 17:55:52 +05:00
//Проверка правильности фокуса, чтобы не выйти за границы массива полей формы, необходимо проверить новое значение фокуса.
//Если фокус стал больше чем количество полей формы, значит он вышел за пределы всей формы и находится уже даже ниже кнопки,
//переводим е г о на первую позицию, то есть на 0.
//Если фокус стал меньше 0, значит он вышел за пределы формы и находится выше первого поля формы,
//переводим е г о на последнюю позицию, то есть на кнопку переключения секции, равную положение которой = количество полей на форме.
2025-12-26 17:45:37 +05:00
if m . focus == len ( m . FieldModels ) + 1 {
m . focus = 0
} else if m . focus < 0 {
m . focus = len ( m . FieldModels )
}
}
}
2026-01-15 17:55:52 +05:00
//Если не было возвращено сообщений, значит действие было произведено с полем, а не с самой формой.
//Возвращаем результат функции обновления полей.
2026-01-16 17:44:03 +05:00
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 )
}
2025-12-26 17:45:37 +05:00
}
2026-01-15 17:55:52 +05:00
// View формирует строку для вывода формы и её контента.
2025-12-26 17:45:37 +05:00
func ( m * FormModel ) View ( ) string {
2026-01-16 17:44:03 +05:00
containerWidth , containerHeight , contentWidth , contentHeight := ui . ContentSizes ( m . width , m . height )
2026-01-14 18:08:43 +05:00
formWidth := contentWidth - 2 * 1
2026-01-16 17:44:03 +05:00
//formHeight:=contentHeight-2*1
_ = contentHeight - 2 * 1
2025-12-29 17:46:48 +05:00
2026-01-16 17:44:03 +05:00
m . rebuildContent ( formWidth )
2026-01-14 18:08:43 +05:00
container := ui . AppContainer .
Width ( containerWidth ) .
Height ( containerHeight ) .
2026-01-16 17:44:03 +05:00
Render ( m . renderedContent )
2026-01-14 18:08:43 +05:00
return lipgloss . Place (
m . width , m . height ,
2025-12-29 17:46:48 +05:00
lipgloss . Center , lipgloss . Center ,
container ,
2026-01-16 17:44:03 +05:00
lipgloss . WithWhitespaceChars ( "0" ) ,
lipgloss . WithWhitespaceForeground ( ui . ColorError ) ,
2025-12-29 17:46:48 +05:00
)
2026-01-16 17:44:03 +05:00
////Данные о размерах контейнера и ширины контента внутри
//containerWidth, containerHeight, contentWidth, _ := ui.ContentSizes(m.width, m.height)
//formWidth := contentWidth - 2*1
//var strokes []string //отображаемые строки
//for i, field := range m.FieldModels {
// //Строка с учетом переноса поля.
// //Имеется ввиду, что если вводимое значение слишком длинное, то оно будет делиться на несколько строк.
// fieldStroke := wordwrap.String(field.View(), formWidth)
// //Если фокус равен текущему полю, то выделяем е г о .
// if i == m.focus {
// strokes = append(strokes, ui.FormFieldFocused.Render(fieldStroke))
// } else {
// strokes = append(strokes, ui.FormFieldUnfocused.Render(fieldStroke))
// }
//}
////Если текущая секция это секция About, то выводим её информацию.
////Н е предполагается, что информация о приложении и поля для настройки будут в одной секции, поэтому тут идет обнуление прошлых строк.
//if m.CurrentSection.Type == types.SectionAbout {
// strokes = append([]string{}, m.renderedContent)
//}
////Строка кнопки переключения секций.
////Если позиция фокуса равняется количеству полей, значит он сейчас находится на кнопке.
//nextBtn := "[ next section ]"
//if m.focus == len(m.FieldModels) {
// strokes = append(strokes, ui.FormNextButton.Render(nextBtn))
//} else {
// strokes = append(strokes, ui.FormFieldUnfocused.Render(nextBtn))
//}
//
////Основной контейнер приложения.
////Устанавливаем ему ширину и высоту, объединяем все строки в одну с соединением, через двойной перевод строки.
//container := ui.AppContainer.
// Width(containerWidth).
// Height(containerHeight).
// Render(strings.Join(strokes, "\n\n"))
//return lipgloss.Place(
// m.width, m.height,
// lipgloss.Center, lipgloss.Center,
// container,
// lipgloss.WithWhitespaceChars(" "),
//)
2025-12-26 17:45:37 +05:00
}
2026-01-15 17:55:52 +05:00
// updateFields обновляет поля и их внутренние данные, формирует единую команду действий для всех полей, в том числе возврат SugarMsg
2025-12-26 17:45:37 +05:00
func ( m * FormModel ) updateFields ( msg tea . Msg ) tea . Cmd {
2026-01-15 17:55:52 +05:00
//Массив команд для выполнения
2025-12-26 17:45:37 +05:00
var cmds [ ] tea . Cmd
for i , fieldModel := range m . FieldModels {
var fieldCmd tea . Cmd
var updated fields . FieldModel
2026-01-15 17:55:52 +05:00
//Если фокус установлен на текущем поле, то нужно активировать е г о и передать ему сообщение для обновления.
//Если фокус не установлен на текущем поле, то снимаем с него фокус и передаем ему сообщение не связанное с нажатием кнопок клавиатуры
if i == m . focus {
2025-12-26 17:45:37 +05:00
fieldModel . Focus ( )
updated , fieldCmd = fieldModel . Update ( msg )
2026-01-15 17:55:52 +05:00
} else {
2025-12-26 17:45:37 +05:00
fieldModel . Blur ( )
if _ , ok := msg . ( tea . KeyMsg ) ; ! ok {
updated , fieldCmd = fieldModel . Update ( msg )
}
}
2026-01-15 17:55:52 +05:00
//Если поле успешно вернуло обновленное поле, то необходимо:
//обновить модель поля в текущей форме;
2026-01-16 17:44:03 +05:00
//проверить у поля не типа Compute изменилось ли значение переменной в настройках приложения,
2026-01-15 17:55:52 +05:00
//если изменилось, в массив команд помещаем команду по возврату сообщения Sugar
2025-12-26 17:45:37 +05:00
if updated != nil {
m . FieldModels [ i ] = updated
for _ , sectionField := range m . CurrentSection . Fields {
2026-01-16 17:44:03 +05:00
if sectionField . Name == fieldModel . GetLabel ( ) && sectionField . FieldType != "compute" {
2025-12-26 17:45:37 +05:00
oldValue := sectionField . Variable . Value
newValue := fieldModel . GetValue ( )
2026-01-16 17:44:03 +05:00
logrus . Debugf ( "label %s, oldValue(%v,%T), newValue(%v,%T), equal %t, field info %+v" ,
sectionField . Name , oldValue , oldValue , newValue , newValue , oldValue == newValue , sectionField ,
)
2025-12-26 17:45:37 +05:00
if oldValue != newValue {
2026-01-16 17:44:03 +05:00
sectionField . Variable . Value = newValue //Записываем в переменную новое значение
2025-12-26 17:45:37 +05:00
cmds = append ( cmds , func ( ) tea . Msg {
return types . SugarMsg {
VariableName : sectionField . VariableName ,
FieldName : sectionField . Name ,
Compute : sectionField . Compute ,
Focus : m . focus ,
}
} )
2026-01-16 17:44:03 +05:00
2025-12-26 17:45:37 +05:00
}
}
}
}
2026-01-15 17:55:52 +05:00
//Если есть команда поля, то также помещаем её в массив команд
2025-12-26 17:45:37 +05:00
if fieldCmd != nil {
cmds = append ( cmds , fieldCmd )
}
}
2026-01-15 17:55:52 +05:00
//Объединяем весь массив команду в одну
2025-12-26 17:45:37 +05:00
return tea . Batch ( cmds ... )
}
2026-01-14 18:08:43 +05:00
2026-01-16 17:44:03 +05:00
func ( m * FormModel ) initViewport ( width , height int ) {
_ , _ , contentWidth , contentHeight := ui . ContentSizes ( width , height )
formWidth := contentWidth - 2 * 1
formHeight := contentHeight - 2 * 4
m . viewport = viewport . New ( contentWidth , formHeight )
m . viewport . SetHorizontalStep ( 1 )
m . rebuildContent ( formWidth )
}
func ( m * FormModel ) rebuildContent ( width int ) {
var strokes [ ] string
if m . CurrentSection . Type == types . SectionAbout {
strokes = append ( strokes , simpleAboutRender ( m . CurrentSection . Paragraphs , width ) )
} else {
for i , field := range m . FieldModels {
//Строка с учетом переноса поля.
//Имеется ввиду, что если вводимое значение слишком длинное, то оно будет делиться на несколько строк.
fieldStroke := wordwrap . String ( field . View ( ) , width )
//Если фокус равен текущему полю, то выделяем е г о .
if i == m . focus {
strokes = append ( strokes , ui . FormFieldFocused . Render ( fieldStroke ) )
} else {
strokes = append ( strokes , ui . FormFieldUnfocused . Render ( fieldStroke ) )
}
}
}
//Строка кнопки переключения секций.
//Если позиция фокуса равняется количеству полей, значит он сейчас находится на кнопке.
nextBtn := "[ next section ]"
if m . focus == len ( m . FieldModels ) {
strokes = append ( strokes , ui . FormNextButton . Render ( nextBtn ) )
} else {
strokes = append ( strokes , ui . FormFieldUnfocused . Render ( nextBtn ) )
}
m . renderedContent = strings . Join ( strokes , "\n\n" )
m . viewport . SetContent ( m . renderedContent )
}
2026-01-15 17:55:52 +05:00
// simpleAboutRender формирует единую строку, описывающую настраиваемое приложение.
2026-01-14 18:08:43 +05:00
func simpleAboutRender ( paragraphs [ ] string , width int ) string {
var sb strings . Builder
for _ , p := range paragraphs {
p = strings . TrimSpace ( p )
if p == "" {
sb . WriteString ( "\n" )
continue
}
switch {
case strings . HasPrefix ( p , "# " ) :
// Заголовок 1 уровня
2026-01-15 17:55:52 +05:00
//h1Len := utf8.RuneCountInString(p[2:])
//h1Offset := strings.Repeat(" ", (width-h1Len)/2)
//sb.WriteString(ui.AboutTitle.Render(h1Offset+p[2:]) + "\n\n")
sb . WriteString ( ui . AboutTitle . Render ( p [ 2 : ] ) + "\n\n" )
2026-01-14 18:08:43 +05:00
case strings . HasPrefix ( p , "## " ) :
// Заголовок 2 уровня
sb . WriteString ( ui . AboutH2 . Render ( p [ 3 : ] ) + "\n\n" )
case strings . HasPrefix ( p , "### " ) :
// Заголовок 3 уровня
sb . WriteString ( " " + ui . AboutH3 . Render ( p [ 4 : ] ) + "\n\n" )
case strings . HasPrefix ( p , "- " ) , strings . HasPrefix ( p , "* " ) :
// Список простой
text := strings . TrimLeft ( p [ 2 : ] , " " )
wrapped := wordwrap . String ( text , width - 10 )
sb . WriteString ( " " + ui . AboutBullet . Render ( "• " ) + ui . AboutNormal . Render ( wrapped ) + "\n" )
case strings . HasPrefix ( p , "> " ) :
// Цитаты
text := strings . TrimPrefix ( p , "> " )
wrapped := wordwrap . String ( text , width - 12 )
sb . WriteString ( " " + ui . AboutNormal . Foreground ( ui . ColorGrey ) . Render ( "│ " + wrapped ) + "\n" )
default :
// Обычный текст
wrapped := wordwrap . String ( p , width - 8 )
sb . WriteString ( " " + ui . AboutNormal . Render ( wrapped ) + "\n\n" )
}
}
return sb . String ( )
}