// Map, wardley map editor for linux
// Copyright (C) 2026 Rubén Beltrán del Río
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see .
use adw::prelude::*;
use relm4::prelude::*;
use sourceview5::LanguageManager;
use sourceview5::prelude::*;
use uuid::Uuid;
use crate::tr;
use crate::actions::PreferencesAction;
use crate::components::template_row;
use crate::preferences::models::Template;
pub struct TemplatesInit {
pub templates: Vec,
pub window: adw::Window,
}
pub struct Templates {
template_rows: FactoryVecDeque,
selected_template: Option,
selected_name: String,
source_view: sourceview5::View,
source: sourceview5::Buffer,
is_collapsed: bool,
is_showing_content: bool,
}
#[derive(Debug, Clone)]
pub enum TemplatesAction {
TemplateAdded(Template),
TemplateUpdated(Template),
TemplateRemoved(Uuid),
UpdateAllTemplates(Vec),
// Routed Via Outputs
ChangeName(DynamicIndex, String),
ChangeDefaultTemplate(DynamicIndex),
Activate(DynamicIndex),
Focus(DynamicIndex),
// Internal
Deactivate,
SelectTemplate(usize),
RemoveTemplate,
SourceChanged(String),
Collapse,
Expand,
}
#[relm4::component(pub)]
impl SimpleComponent for Templates {
type Init = TemplatesInit;
type Input = TemplatesAction;
type Output = PreferencesAction;
view! {
#[name = "split_view"]
adw::NavigationSplitView {
set_collapsed: true,
#[watch]
set_show_content: model.is_showing_content,
connect_show_content_notify[sender] => move |nav| {
if !nav.shows_content() {
sender.input(TemplatesAction::Deactivate);
}
},
#[wrap(Some)]
set_sidebar = &adw::NavigationPage {
set_title: &tr!("Templates"),
gtk::Box {
set_orientation: gtk::Orientation::Vertical,
set_margin_all: 20,
set_spacing: 10,
gtk::Label {
set_label: &tr!("Templates"),
add_css_class: "heading",
set_halign: gtk::Align::Start,
set_margin_start: 5,
},
model.template_rows.widget() -> >k::ListBox {
set_selection_mode: gtk::SelectionMode::Single,
add_css_class: "boxed-list",
connect_row_selected[sender] => move |_, row| {
if let Some(row) = row {
#[allow(clippy::cast_sign_loss)]
let index = row.index() as usize;
sender.input(TemplatesAction::SelectTemplate(index));
}
}
},
gtk::Box {
set_spacing: 5,
set_halign: gtk::Align::End,
gtk::Button::from_icon_name("list-remove-symbolic") {
set_tooltip_text: Some(&tr!("Remove template")),
connect_clicked[sender] => move |_| {
sender.input(TemplatesAction::RemoveTemplate);
}
},
gtk::Button::from_icon_name("list-add-symbolic") {
set_tooltip_text: Some(&tr!("Add template")),
connect_clicked[sender] => move |_| {
sender.output(PreferencesAction::AddTemplate(tr!(
"My Template"
))).ok();
},
},
},
},
},
#[wrap(Some)]
set_content = &adw::NavigationPage {
set_title: &model.selected_name,
gtk::Box {
set_orientation: gtk::Orientation::Vertical,
set_spacing: 0,
adw::HeaderBar {
set_show_end_title_buttons: false,
set_show_start_title_buttons: false,
#[wrap(Some)]
set_title_widget = if !model.template_rows.is_empty() || model.selected_template.is_none() {
>k::Label {
#[watch]
set_label: &model.selected_name,
add_css_class: "heading",
set_hexpand: true,
set_halign: gtk::Align::Start,
}
} else {
>k::Label {
#[watch]
set_label: "",
add_css_class: "heading",
set_hexpand: true,
set_halign: gtk::Align::Start,
}
}
},
gtk::Box {
set_orientation: gtk::Orientation::Vertical,
set_spacing: 5,
set_hexpand: true,
if !model.template_rows.is_empty() && model.selected_template.is_some() {
gtk::ScrolledWindow {
set_vexpand: true,
set_hexpand: true,
#[local_ref]
source_view -> sourceview5::View {
set_expand: true,
set_buffer: Some(&model.source),
set_top_margin: 8,
set_bottom_margin: 8,
set_left_margin: 8,
set_right_margin: 8
}
}
} else {
gtk::Label {
set_label: &tr!("There are no templates. Click on the + button to create one."),
add_css_class: "dim-label",
set_hexpand: true,
set_vexpand: true,
}
}
},
},
},
}
}
fn init(
init: Self::Init,
root: Self::Root,
sender: ComponentSender,
) -> ComponentParts {
let mut template_rows = FactoryVecDeque::builder()
.launch(gtk::ListBox::default())
.forward(sender.input_sender(), |output| match output {
template_row::TemplateRowOutput::SetName(index, name) => {
TemplatesAction::ChangeName(index, name)
}
template_row::TemplateRowOutput::SetDefault(index) => {
TemplatesAction::ChangeDefaultTemplate(index)
}
template_row::TemplateRowOutput::Activate(index) => {
TemplatesAction::Activate(index)
}
template_row::TemplateRowOutput::Focus(index) => TemplatesAction::Focus(index),
});
let first = init.templates.first();
let selected_template = first.cloned();
let source = Self::create_source_buffer(first, &sender);
{
let mut guard = template_rows.guard();
for template in init.templates {
guard.push_back(template);
}
}
let source_view = Self::init_source_view(&source);
let selected_name = selected_template.clone().map_or(String::new(), |t| t.name);
let model = Self {
selected_template,
selected_name,
template_rows,
source_view: source_view.clone(),
source,
is_collapsed: false,
is_showing_content: false,
};
let widgets = view_output!();
Self::init_breakpoints(&init.window, &widgets, &sender);
ComponentParts { model, widgets }
}
fn update(&mut self, message: Self::Input, sender: ComponentSender) {
match message {
TemplatesAction::TemplateAdded(template) => {
self.template_rows.guard().push_back(template);
}
TemplatesAction::TemplateUpdated(template) => {
if let Some(row) = self
.template_rows
.guard()
.iter_mut()
.find(|s| s.template.id == template.id)
{
row.template.name = template.name;
row.template.is_default = template.is_default;
row.template.content = template.content;
}
}
TemplatesAction::TemplateRemoved(id) => {
let mut guard = self.template_rows.guard();
let index = guard.iter().position(|s| s.template.id == id);
if let Some(index) = index {
guard.remove(index);
if self.selected_template.as_ref().is_some_and(|t| id == t.id) {
guard.drop();
self.select_template(0, &sender);
}
}
}
TemplatesAction::RemoveTemplate => {
if let Some(template) = &self.selected_template {
sender
.output(PreferencesAction::RemoveTemplate(template.id))
.ok();
}
}
TemplatesAction::UpdateAllTemplates(templates) => {
let mut guard = self.template_rows.guard();
guard.clear();
for template in templates {
guard.push_back(template);
}
guard.drop();
let index = if let Some(template) = &self.selected_template {
self.template_rows
.guard()
.iter()
.position(|s| s.template.id == template.id)
.map_or(0, |i| i)
} else {
0
};
self.select_template(index, &sender);
}
TemplatesAction::ChangeName(index, name) => {
if let Some(row) = self.template_rows.get(index.current_index()) {
sender
.output(PreferencesAction::SetTemplateName(row.template.id, name))
.ok();
}
}
TemplatesAction::ChangeDefaultTemplate(index) => {
let index = index.current_index();
if let Some(row) = self.template_rows.get(index) {
sender
.output(PreferencesAction::SetDefaultTemplate(row.template.id))
.ok();
}
self.select_template(index, &sender);
}
TemplatesAction::Activate(index) => {
self.select_template(index.current_index(), &sender);
self.is_showing_content = true;
}
TemplatesAction::Deactivate => {
self.is_showing_content = false;
}
TemplatesAction::Focus(index) => {
self.select_template(index.current_index(), &sender);
}
TemplatesAction::SelectTemplate(index) => {
self.select_template(index, &sender);
}
TemplatesAction::SourceChanged(source) => {
if let Some(template) = &self.selected_template {
sender
.output(PreferencesAction::SetTemplateContent(template.id, source))
.ok();
}
}
TemplatesAction::Collapse => {
self.is_collapsed = true;
self.template_rows
.broadcast(template_row::TemplateRowAction::ShowArrow);
}
TemplatesAction::Expand => {
self.is_collapsed = false;
self.is_showing_content = false;
self.template_rows
.broadcast(template_row::TemplateRowAction::HideArrow);
}
}
}
}
impl Templates {
// Initializes the breakpoint logic
fn init_breakpoints(
root: &adw::Window,
widgets: &TemplatesWidgets,
sender: &ComponentSender,
) {
let breakpoint = adw::Breakpoint::new(adw::BreakpointCondition::new_length(
adw::BreakpointConditionLengthType::MinWidth,
550.0,
adw::LengthUnit::Sp,
));
breakpoint.add_setter(&widgets.split_view, "collapsed", Some(&false.to_value()));
breakpoint.add_setter(
&widgets.source_view,
"show-line-numbers",
Some(&true.to_value()),
);
{
let sender = sender.clone();
breakpoint.connect_apply(move |_| {
sender.input(TemplatesAction::Expand);
});
}
{
let sender = sender.clone();
breakpoint.connect_unapply(move |_| {
sender.input(TemplatesAction::Collapse);
});
}
root.add_breakpoint(breakpoint);
}
fn create_source_buffer(
template: Option<&Template>,
sender: &ComponentSender,
) -> sourceview5::Buffer {
let source = sourceview5::Buffer::new(None);
source.set_highlight_syntax(true);
let language_manager = LanguageManager::default();
if let Some(language) = language_manager.language("wmap") {
source.set_language(Some(&language));
}
if let Some(template) = template {
source.set_text(&template.content);
}
{
let sender = sender.clone();
source.connect_changed(move |buffer| {
let start = buffer.start_iter();
let end = buffer.end_iter();
let content = buffer.text(&start, &end, false).to_string();
sender.input(TemplatesAction::SourceChanged(content));
});
}
source
}
fn init_source_view(source: &sourceview5::Buffer) -> sourceview5::View {
let source_view = sourceview5::View::with_buffer(source);
source_view.set_monospace(true);
source_view.add_css_class("map-editor");
source_view.set_show_line_numbers(false);
source_view.set_width_request(-1);
source_view.set_tab_width(2);
source_view
}
fn select_template(&mut self, index: usize, sender: &ComponentSender) {
let guard = self.template_rows.guard();
if let Some(row) = guard.get(index) {
self.selected_template = Some(row.template.clone());
self.selected_name = row.template.clone().name;
self.source = Self::create_source_buffer(Some(&row.template), sender);
self.source_view.set_buffer(Some(&self.source));
}
guard.drop();
let widget = self.template_rows.widget();
if let Ok(gtk_index) = i32::try_from(index) {
let potential_row_widget = widget.row_at_index(gtk_index);
widget.select_row(potential_row_widget.as_ref());
}
}
}