TypeBubbleX Devlog #06: Voltando à ativa e criando a Janela de Configuração Inicial
Bem, depois de 3 meses parado, vamos finalmente voltar a esse projeto!
Como eu disse no final do meu último post, o foco agora é a interface gráfica (GUI). Já aviso de antemão: a ideia inicial não é fazer algo visualmente lindo ou ultra estilizado, mas sim focar na organização estrutural para que eu possa aplicar temas facilmente depois.
Para começar, decidi tirar do papel o que planejei naquele post sobre wireframes: a Janela de Configuração Inicial (Initial Setup).
O Objetivo da Janela (O Fluxo de Trabalho)
A ideia principal dessa janela é definir e configurar qual diretório do computador o TypeBubbleX terá controle total para gerenciar as obras de scanlation.
Desenhei o comportamento dessa lógica para funcionar assim na inicialização do app:
- Primeiro acesso: O app verifica uma variável de preferência. Se não houver nenhum caminho salvo, a janela de setup se abre na tela.
- Caminho válido encontrado: Se o diretório já estiver configurado e o arquivo de banco de dados (
typebubblex.db) estiver lá dentro, o setup é ignorado e o app vai direto para a tela de obras. - Recuperação de dados antigos: Digamos que o usuário parou de usar o app, deletou o executável, mas manteve a pasta com as obras no computador. Se ele baixou o app de novo e apontar para essa mesma pasta, o sistema reconhece o banco de dados antigo e importa tudo automaticamente, sem que ele perca o progresso.
- Tratamento de erros: Se o usuário mover a pasta ou deletar o arquivo
.dbmanualmente, o app detecta o problema e exibe uma notificação avisando que o workspace sumiu, forçando a abertura do setup para correção.
Com esses comportamentos definidos, fui para a engine estruturar a cena.
Estruturando a árvore de nós (Scene Tree)
Abaixo, mostro de como ficou a árvore de nós:
O nó raiz é uma Window chamada InitialSetup, que carrega o script responsável por toda a mágica da validação (que vou destrinchar daqui a pouco).
Dentro da Window, usei um PanelContainer (que servirá para eu estilizar a interface mais para a frente) e, dentro dele, um VBoxContainer para fazer com que todos os elementos fiquem organizados verticalmente, um abaixo do outro.
Para a organização interna, usei três HBoxContainer, que são os nós responsáveis por alinhar os elementos na horizontal, deixando um ao lado do outro (como a linha do caminho e a linha dos botões).
Além dos containers, a estrutura conta com os seguintes nós principais:
CreateFolderCheckButton: Um botão de alternância (checkbutton) para o usuário decidir se quer ou não criar automaticamente o diretório final.PathLineEdit: O campo de entrada de texto onde o caminho do diretório é exibido e pode ser digitado.FileDialogButton: O botão que abre o explorador de arquivos nativo para selecionar o diretório de forma muito mais fácil.MessageLabel: O texto de feedback que mostrará em tempo real se está tudo certo ou se há algo de errado com o caminho.ActionButton: O botão dinâmico (“Create” ou “Import”) que conclui todo esse processo de configuração.
Mostrando o código
Vou dividir o código em vários blocos para explicar cada um deles.
1
2
3
4
5
6
7
8
9
extends Window
signal setup_completed
@onready var path_line_edit : LineEdit = $PanelContainer/VBoxContainer/PathHBox/PathLineEdit
@onready var message_label : Label = $PanelContainer/VBoxContainer/MessageLabel
@onready var create_folder_check_button : CheckButton = $PanelContainer/VBoxContainer/HBoxContainer/CreateFolderCheckButton
@onready var file_dialog : FileDialog = $FileDialog
@onready var action_button : Button = $PanelContainer/VBoxContainer/ActionHBox/ActionButton
Começamos declarando um signal que será emitido para avisar à janela principal que deu tudo certo. Além desse sinal, temos algumas variáveis para que possamos manipular os elementos da interface via script.
Caso você nunca tenha trabalhado com Godot, o @onready indica ao script que ele deve esperar até que os nós da cena estejam totalmente carregados antes de tentar obter as referências deles. É uma lógica parecida com o JavaScript, quando usamos o window.onload (ou algo semelhante) para garantir que a página foi carregada completamente antes de executar uma ação.
1
2
3
4
5
6
7
8
9
10
const DEFAULT_DATABASE_FILE_NAME : String = "typebubblex.db"
const DEFAULT_DIR_NAME : String = "TypeBubbleX"
const MSG_WARNING_NOT_EMPTY = "The selected path is not empty. Choosing an empty folder is highly recommended."
const MSG_ERROR_PATH_NOT_FOUND = "The path specified doesn't exist."
const MSG_INFO_AUTO_CREATE_FOLDER = "The project folder will be automatically created."
const MSG_INFO_PATH_EMPTY = "The project folder exists and is empty."
const MSG_INFO_LOAD_EXISTING = "A valid project file was found. The project will be loaded from this directory."
enum Status { ERROR_PATH_NOT_FOUND, WARNING_NOT_EMPTY, INFO_AUTO_CREATE_FOLDER, INFO_PATH_EMPTY, INFO_LOAD_EXISTING }
Aqui declaramos as nossas constantes e um enum. Não tem muito segredo nessa parte, as primeiras constantes definem os nomes padrão de arquivos e pastas, enquanto as constantes que começam com MSG_ centralizam as mensagens que exibiremos para o usuário, essa parte sofrerá mudanças no futuro para aplicar o i18n para traduzir em outras linguas.
Já o enum (Status) serve para organizar os estados possíveis da validação. Eu gosto de usar enums porque eles nos permitem aplicar tipagem estática em variáveis ou parâmetros de funções, garantindo que o código só aceite os valores que nós definimos aqui, evitando bugs.
1
2
3
var current_path : String = OS.get_system_dir(OS.SYSTEM_DIR_DOCUMENTS).path_join(DEFAULT_DIR_NAME)
var current_status : Status = Status.INFO_AUTO_CREATE_FOLDER
var should_create_folder : bool = true
Aqui temos as variáveis que guardarão o caminho escolhido pelo usuário, o status atual da validação e se o diretório deve ou não ser criado.
Você pode notar que a variável current_status está utilizando o Status (nosso enum) como tipo. Isso garante que ela receba apenas os valores que definimos anteriormente.
Uma observação importante: por baixo dos panos, os enums na Godot são representados por números inteiros. Isso significa que, tecnicamente, a variável poderia receber um número, mas o motor da Godot não recomenda esse tipo de comportamento e o editor de código vai chamar a sua atenção (emitindo um warning ou erro de tipo) para garantir que você mantenha o código limpo e seguro.
1
2
3
4
func _ready() -> void:
path_line_edit.text = current_path
create_folder_check_button.set_pressed_no_signal(should_create_folder)
_validate_and_update_path(current_path)
Nesta função, que é chamada automaticamente pela Godot assim que o nó e seus filhos estão prontos na cena, nós inicializamos os elementos da interface com os nossos valores padrão.
Um detalhe interessante aqui é o uso do método set_pressed_no_signal(). Nós o utilizamos para definir o estado inicial do botão de checagem (CheckButton) sem disparar o sinal de ‘clique’. Isso é uma ótima prática para evitar que funções conectadas a esse botão sejam executadas antes da hora, garantindo que a inicialização da tela seja limpa. Por fim, chamamos a função de validação para checar o caminho padrão inicial.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
func _validate_and_update_path(target_path : String) -> void:
current_path = target_path
if _is_valid_path(target_path):
if _has_files_in_folder(target_path):
if _has_database_project_file(target_path):
_update_status(Status.INFO_LOAD_EXISTING)
else:
_update_status(Status.WARNING_NOT_EMPTY)
else:
if should_create_folder:
_update_status(Status.INFO_AUTO_CREATE_FOLDER)
else:
_update_status(Status.INFO_PATH_EMPTY)
else:
if should_create_folder and _is_valid_path(target_path.get_base_dir()):
_update_status(Status.INFO_AUTO_CREATE_FOLDER)
else:
_update_status(Status.ERROR_PATH_NOT_FOUND)
Esta função é o coração da nossa validação. Nela, recebemos o caminho definido pelo usuário e passamos por uma estrutura de condições (if/else) para atualizar o status da tela, permitindo que o usuário saiba exatamente se o diretório escolhido é adequado ou não.
A lógica segue uma tomada de decisão em cascata:
- Se o caminho for válido: ela verifica se a pasta contém arquivos. Caso contenha, checa se o arquivo de banco de dados do projeto já existe ali (para carregá-lo) ou se é apenas uma pasta ocupada (gerando um aviso). Se estiver vazia, define o status com base na opção de criar ou não a pasta.
- Se o caminho NÃO for válido: ela ainda faz uma última tentativa útil, checando se a pasta mãe (o diretório base) existe e se a opção de criar a pasta automaticamente está ativa. Se nada disso bater, ela exibe o erro de caminho não encontrado.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
func _update_status(new_status : Status) -> void:
current_status = new_status
action_button.disabled = false
match new_status:
Status.ERROR_PATH_NOT_FOUND:
message_label.text = MSG_ERROR_PATH_NOT_FOUND
action_button.text = "Create"
action_button.disabled = true
Status.WARNING_NOT_EMPTY:
message_label.text = MSG_WARNING_NOT_EMPTY
message_label.self_modulate = Color.YELLOW
action_button.text = "Create"
Status.INFO_AUTO_CREATE_FOLDER:
message_label.text = MSG_INFO_AUTO_CREATE_FOLDER
message_label.self_modulate = Color.GREEN
action_button.text = "Create"
Status.INFO_PATH_EMPTY:
message_label.text = MSG_INFO_PATH_EMPTY
message_label.self_modulate = Color.GREEN
action_button.text = "Create"
Status.INFO_LOAD_EXISTING:
message_label.text = MSG_INFO_LOAD_EXISTING
message_label.self_modulate = Color.GREEN
action_button.text = "Import"
Nesta função, nós atualizamos o estado atual do componente e alteramos a interface gráfica para refletir visualmente o que está acontecendo com a validação.
Utilizando a estrutura match da Godot, conseguimos mudar o comportamento da tela para cada estado do nosso enum:
- Mensagens Dinâmicas: O texto da label (
message_label.text) assume a constante correspondente que criamos lá no início. - Feedback por Cores: Usamos a propriedade
self_modulatepara colorir a mensagem, o que ajuda o usuário a entender a situação num piscar de olhos. - Mudança no Botão: O botão de ação muda não apenas o seu comportamento (ficando desabilitado em caso de erro), mas também o seu texto. Se o projeto já existe, o botão vira ‘Import’; se for um caminho novo, ele exibe ‘Create’. Isso melhora drasticamente a experiência do usuário (UX).”
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
func _is_valid_path(target_path : String) -> bool:
return DirAccess.dir_exists_absolute(target_path)
func _has_files_in_folder(target_path : String) -> bool:
var dir : DirAccess = DirAccess.open(target_path)
if dir:
dir.list_dir_begin()
var file_name : String = dir.get_next()
if file_name != "":
return true
return false
func _has_database_project_file(target_path : String) -> bool:
var project_file_path = target_path.path_join(DEFAULT_DATABASE_FILE_NAME)
return FileAccess.file_exists(project_file_path)
Aqui temos o trio de funções auxiliares que realizam a checagem física no sistema de arquivos do computador, utilizando as classes DirAccess e FileAccess da Godot:
_is_valid_path: Uma função simples que retorna um valor booleano (trueoufalse) indicando se o caminho de diretório informado realmente existe no sistema._has_files_in_folder: Esta função abre o diretório e inicia uma leitura interna dele com olist_dir_begin(). Chamamos oget_next()para pegar o primeiro item encontrado. Se esse primeiro item for diferente de um texto vazio (“”), sabemos imediatamente que a pasta possui algum conteúdo (seja um arquivo ou outra subpasta) e retornamostrue._has_database_project_file: Responsável por testar a existência do projeto em si. Ela combina o caminho atual com o nome padrão do banco de dados (DEFAULT_DATABASE_FILE_NAME) usando o métodopath_joine verifica se esse arquivo específico existe.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
func _on_create_folder_check_button_toggled(toggled_on : bool) -> void:
var path_text : String = path_line_edit.text.strip_edges()
should_create_folder = toggled_on
if toggled_on:
var base_dir = path_text
if base_dir.get_file() != DEFAULT_DIR_NAME:
path_text = base_dir.path_join(DEFAULT_DIR_NAME)
else:
if path_text.get_file() != "":
path_text = path_text.get_base_dir()
path_line_edit.text = path_text
_validate_and_update_path(path_text)
Esta função é acionada automaticamente sempre que o usuário ativa ou desativa o CheckButton (botão de checagem). Ela é responsável por atualizar dinamicamente o campo de texto, adicionando ou removendo o nome da pasta padrão do projeto.
A lógica funciona assim:
- Limpeza inicial: Primeiro, usamos o
.strip_edges()para garantir que qualquer espaço em branco invisível digitado sem querer no início ou fim do caminho seja removido. - Se o botão for ativado (
toggled_on): O código verifica se o caminho já não termina com o nome padrão da pasta (DEFAULT_DIR_NAME). Se não terminar, ele anexa esse nome ao final usando opath_join(). - Se o botão for desativado: O código faz o inverso. Usando o
get_base_dir(), ele ‘sobe um nível’, removendo o nome da pasta do final do caminho.
Por fim, o texto do campo de entrada (path_line_edit.text) é atualizado com o novo caminho e a função de validação é chamada novamente para recalcular o status da tela.
1
2
func _on_path_line_edit_text_changed(new_text : String) -> void:
_validate_and_update_path(new_text)
Esta função é acionada automaticamente sempre que o usuário digita ou altera qualquer coisa no campo de texto, chamando imediatamente a nossa função de validação para atualizar o status em tempo real.
1
2
func _on_file_dialog_button_pressed() -> void:
file_dialog.popup_centered()
Esta função é acionada quando o usuário clica no botão, abrindo a janela de seleção de diretórios (FileDialog). O método popup_centered() garante que essa janela apareça centralizada na tela do usuário.
1
2
3
4
5
6
func _on_file_dialog_dir_selected(dir : String) -> void:
if should_create_folder:
dir = dir.path_join(DEFAULT_DIR_NAME)
path_line_edit.text = dir
_validate_and_update_path(dir)
Esta função é acionada quando o usuário seleciona um diretório e confirma a escolha (clicando em ‘OK’) na janela de seleção.
Ao receber o caminho escolhido, ela verifica se a opção de criar a pasta automaticamente está ativa (should_create_folder). Se estiver, ela anexa o nome padrão da pasta (DEFAULT_DIR_NAME) ao final do caminho. Em seguida, atualiza o campo de texto na tela e dispara a validação para recalcular o status do novo diretório.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
func _on_action_button_pressed() -> void:
if current_status == Status.ERROR_PATH_NOT_FOUND:
return
var is_existing_project : bool = current_status == Status.INFO_LOAD_EXISTING
if not is_existing_project:
if not _is_valid_path(current_path):
var err : Error = DirAccess.make_dir_absolute(current_path)
if err != OK:
_update_status(Status.ERROR_PATH_NOT_FOUND)
return
DBClient.open_db(current_path.path_join(DEFAULT_DATABASE_FILE_NAME))
Preference.data.workspace_dir = current_path
Preference.save_preferences()
setup_completed.emit()
queue_free()
Esta última função é a que finaliza todo o processo de configuração. Ela é acionada quando o usuário clica no botão de ação principal — que pode ser ‘Create’ ou ‘Import’, dependendo do status do diretório.
O método executa uma sequência firme de passos para consolidar a ação:
- Criação física da pasta: Se for um projeto novo e a pasta ainda não existir no computador, o código tenta criá-la usando
DirAccess.make_dir_absolute(). Se algo falhar (como falta de permissão do sistema), ele interrompe a execução. - Inicialização do Banco de Dados: Ele abre (ou cria) o arquivo de banco de dados (
.db) diretamente no caminho selecionado através de uma classe gerenciadora (DBClient). - Salvamento de Preferências: Guarda o caminho desse diretório nas configurações globais do aplicativo (
Preference) para que o usuário não precise passar por essa tela toda vez que abrir o app. - Finalização: Emite o sinal
setup_completed(aquele que declaramos lá no primeiro bloco) para avisar o restante do projeto que deu tudo certo e, por fim, usa oqueue_free()para fechar e remover esta janela da memória.
Com isso, terminamos esta parte da configuração inicial.
Abaixo, você pode ver uma imagem de como ficou a interface gráfica. Lembre-se de que ela sofrerá alterações visuais mais para a frente, quando trabalharmos com os temas da Godot, mas a estrutura e a disposição dos elementos serão bem parecidas com estas.
Bem, ainda falta um último detalhe para de fato concluirmos isso, integrar essa janela de configuração à janela principal do projeto. Então, vamos lá!
Integrando com a janela principal
Estrutura de Nós
Para começarmos a integração, precisamos primeiro entender como a nossa cena principal está estruturada no painel Scene da Godot. A organização dos nós é bastante simples e direta:
Como você pode ver na imagem acima, temos a seguinte hierarquia:
MainWindow(Control): O nó raiz da nossa tela principal, responsável por gerenciar o ciclo de vida inicial da aplicação. É nele que anexaremos o script de integração que veremos a seguir.InitialSetup(Window): Esta é a cena da nossa janela de configuração que criamos e detalhamos nos blocos anteriores, instanciada aqui como filha direta da janela principal.ErrorDialog(AcceptDialog): Um nó nativo da Godot perfeito para exibir mensagens de alerta simples com um botão de confirmação. Ele ficará responsável por avisar o usuário caso o banco de dados antigo tenha sumido.
Com essa estrutura visual em mente, vamos agora criar o script da MainWindow para controlar esse fluxo.
Mostrando o código
1
2
3
4
5
6
7
8
extends Control
@onready var initial_setup_window : Window = $InitialSetup
@onready var error_dialog : AcceptDialog = $ErrorDialog
func _ready() -> void:
initial_setup()
Neste início, o nosso script herda de Control, indicando que esta é a interface principal da aplicação. Usamos o @onready para capturar as referências da janela de configuração inicial (que acabamos de criar nos passos anteriores) e de uma janela de diálogo de erro (AcceptDialog), que usaremos caso algo dê errado com o diretório salvo.
Assim que a aplicação fica pronta, a função _ready() entra em ação e chama imediatamente a função initial_setup(), que cuidará de decidir o que deve ser exibido para o usuário logo de cara.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
func initial_setup() -> void:
if Preference.data.workspace_dir.is_empty():
initial_setup_window.setup_completed.connect(_initialize)
initial_setup_window.show()
error_dialog.queue_free()
return
var db_path = Preference.data.workspace_dir.path_join("typebubblex.db")
if DirAccess.dir_exists_absolute(Preference.data.workspace_dir) and FileAccess.file_exists(db_path):
DBClient.open_db(db_path)
_initialize()
initial_setup_window.queue_free()
error_dialog.queue_free()
else:
error_dialog.title = "Workspace Error"
error_dialog.dialog_text = "The previously saved workspace directory or database file could not be found.\n\nPlease select or create a valid workspace to continue."
error_dialog.popup_centered()
Esta função é o filtro de entrada da nossa aplicação. Ela precisa cobrir três cenários possíveis quando o usuário abre o programa:
- Primeira Inicialização (Sem caminho salvo): Se o
workspace_direstiver vazio, significa que é a primeira vez que o app roda. Nós conectamos o sinalsetup_completedda nossa janela de configuração à função_initialize(), exibimos a tela de setup com o.show()e liberamos a janela de erro da memória com oqueue_free(), já que ela não será necessária. - Caminho Salvo e Válido: Se já existe um caminho nas preferências, o código verifica se a pasta e o banco de dados (
typebubblex.db) realmente existem no computador. Se tudo estiver correto, o banco de dados é aberto peloDBClient, a aplicação é inicializada e as janelas auxiliares de setup e erro são descartadas para poupar memória. - Caminho Corrompido ou Apagado: Se o usuário apagou ou moveu a pasta antiga manualmente pelo sistema operacional, a validação falha. Em vez de crashar o app, configuramos um texto explicativo no
error_dialoge o exibimos centralizado, alertando que o espaço de trabalho anterior sumiu.
1
2
3
4
5
6
7
8
9
10
func _initialize() -> void:
# To do
pass
func _on_error_dialog_confirmed() -> void:
initial_setup_window.setup_completed.connect(_initialize)
initial_setup_window.show()
error_dialog.queue_free()
Por fim, temos duas funções que resolvem o desfecho do fluxo:
_initialize(): Esta é a função que carregará o restante da aplicação de fato. Por enquanto, ela está com o marcadorpass(o nosso famoso To Do), pois faremos isso logo em seguida._on_error_dialog_confirmed(): Caso o usuário tenha caído no cenário de erro (diretório sumiu ou arquivo sumiu) e clique no botão de “OK” da janela de alerta, este sinal é disparado. Ele remove a janela de erro da memória e traz de volta a nossa tela deInitialSetuppara que o usuário possa escolher ou criar uma nova pasta válida, recomeçando o fluxo com segurança.
E agora, finalmente, terminamos!
No próximo post, vou abordar o gerenciador de obras ou talvez a barra de menu — mas acho que a barra de menu vai acabar vindo primeiro.
Então é isso, muito obrigado por acompanhar até aqui e nos vemos no próximo post!


