use bevy::input::keyboard::KeyboardInput;
use bevy::prelude::*;
use crate::dice3d::types::*;
const TAB_ACTIVE_BG: Color = Color::srgb(0.2, 0.4, 0.6);
const TAB_INACTIVE_BG: Color = Color::srgb(0.15, 0.15, 0.2);
const TAB_HOVER_BG: Color = Color::srgb(0.25, 0.35, 0.45);
const PANEL_BG: Color = Color::srgb(0.1, 0.1, 0.15);
const GROUP_BG: Color = Color::srgb(0.12, 0.12, 0.18);
const FIELD_BG: Color = Color::srgb(0.08, 0.08, 0.12);
const FIELD_MODIFIED_BG: Color = Color::srgb(0.2, 0.15, 0.08); const BUTTON_BG: Color = Color::srgb(0.2, 0.5, 0.3);
#[allow(dead_code)]
const BUTTON_HOVER: Color = Color::srgb(0.25, 0.6, 0.35);
const TEXT_PRIMARY: Color = Color::WHITE;
const TEXT_SECONDARY: Color = Color::srgb(0.7, 0.7, 0.7);
const TEXT_MUTED: Color = Color::srgb(0.5, 0.5, 0.5);
const PROFICIENT_COLOR: Color = Color::srgb(0.3, 0.7, 0.3);
const ICON_EDIT: &str = "✎";
const ICON_CHECK: &str = "✓";
const ICON_CANCEL: &str = "✕";
const ICON_DELETE: &str = "✕";
const ICON_BUTTON_BG: Color = Color::srgba(0.0, 0.0, 0.0, 0.0); const ICON_BUTTON_ACTIVE: Color = Color::srgb(0.3, 0.5, 0.4);
pub fn setup_tab_bar(mut commands: Commands, icon_assets: Res<IconAssets>) {
commands
.spawn((
NodeBundle {
style: Style {
position_type: PositionType::Absolute,
top: Val::Px(0.0),
left: Val::Px(0.0),
right: Val::Px(0.0),
height: Val::Px(40.0),
flex_direction: FlexDirection::Row,
align_items: AlignItems::Center,
padding: UiRect::horizontal(Val::Px(10.0)),
column_gap: Val::Px(5.0),
..default()
},
background_color: BackgroundColor(Color::srgb(0.08, 0.08, 0.1)),
z_index: ZIndex::Global(100),
..default()
},
TabBar,
))
.with_children(|parent| {
spawn_tab_button(
parent,
&icon_assets,
"Dice Roller",
IconType::Dice,
AppTab::DiceRoller,
true,
);
spawn_tab_button(
parent,
&icon_assets,
"Character",
IconType::Character,
AppTab::CharacterSheet,
false,
);
spawn_tab_button(
parent,
&icon_assets,
"DnD Rolling Info",
IconType::Info,
AppTab::DndInfo,
false,
);
spawn_tab_button(
parent,
&icon_assets,
"Contributors",
IconType::Character,
AppTab::Contributors,
false,
);
});
}
fn spawn_tab_button(
parent: &mut ChildBuilder,
icon_assets: &IconAssets,
text: &str,
icon_type: IconType,
tab: AppTab,
is_active: bool,
) {
let icon_handle = icon_assets.icons.get(&icon_type).cloned();
parent
.spawn((
ButtonBundle {
style: Style {
padding: UiRect::axes(Val::Px(12.0), Val::Px(8.0)),
border: UiRect::all(Val::Px(1.0)),
flex_direction: FlexDirection::Row,
align_items: AlignItems::Center,
column_gap: Val::Px(6.0),
..default()
},
background_color: BackgroundColor(if is_active {
TAB_ACTIVE_BG
} else {
TAB_INACTIVE_BG
}),
border_color: BorderColor(if is_active {
Color::srgb(0.4, 0.6, 0.8)
} else {
Color::srgb(0.2, 0.2, 0.3)
}),
..default()
},
TabButton { tab },
))
.with_children(|button| {
if let Some(handle) = icon_handle {
button.spawn(ImageBundle {
image: UiImage::new(handle),
style: Style {
width: Val::Px(20.0),
height: Val::Px(20.0),
..default()
},
..default()
});
}
button.spawn(TextBundle::from_section(
text,
TextStyle {
font_size: 16.0,
color: if is_active {
TEXT_PRIMARY
} else {
TEXT_SECONDARY
},
..default()
},
));
});
}
pub fn setup_character_screen(
mut commands: Commands,
character_data: Res<CharacterData>,
character_manager: Res<CharacterManager>,
edit_state: Res<GroupEditState>,
adding_state: Res<AddingEntryState>,
icon_assets: Res<IconAssets>,
) {
commands
.spawn((
NodeBundle {
style: Style {
position_type: PositionType::Absolute,
top: Val::Px(45.0), left: Val::Px(0.0),
right: Val::Px(0.0),
bottom: Val::Px(0.0),
flex_direction: FlexDirection::Row,
..default()
},
background_color: BackgroundColor(PANEL_BG),
visibility: Visibility::Hidden,
..default()
},
CharacterScreenRoot,
))
.with_children(|parent| {
spawn_character_list_panel(parent, &character_manager, &character_data);
spawn_character_stats_panel(
parent,
&character_data,
&edit_state,
&adding_state,
&icon_assets,
);
});
}
fn spawn_character_list_panel(
parent: &mut ChildBuilder,
character_manager: &CharacterManager,
character_data: &CharacterData,
) {
parent
.spawn((
NodeBundle {
style: Style {
width: Val::Px(250.0),
flex_direction: FlexDirection::Column,
padding: UiRect::all(Val::Px(10.0)),
row_gap: Val::Px(5.0),
border: UiRect::right(Val::Px(2.0)),
overflow: Overflow::clip_y(),
..default()
},
background_color: BackgroundColor(Color::srgb(0.08, 0.08, 0.12)),
border_color: BorderColor(Color::srgb(0.2, 0.2, 0.3)),
..default()
},
CharacterListPanel,
))
.with_children(|panel| {
panel.spawn(TextBundle::from_section(
"Characters",
TextStyle {
font_size: 18.0,
color: TEXT_PRIMARY,
..default()
},
));
panel
.spawn((
ButtonBundle {
style: Style {
padding: UiRect::axes(Val::Px(10.0), Val::Px(6.0)),
margin: UiRect::vertical(Val::Px(5.0)),
justify_content: JustifyContent::Center,
..default()
},
background_color: BackgroundColor(BUTTON_BG),
..default()
},
NewCharacterButton,
))
.with_children(|btn| {
btn.spawn(TextBundle::from_section(
"+ New Character",
TextStyle {
font_size: 14.0,
color: TEXT_PRIMARY,
..default()
},
));
});
for (i, char_file) in character_manager.available_characters.iter().enumerate() {
let is_current = character_manager
.current_character_path
.as_ref()
.map(|p| p == &char_file.path)
.unwrap_or(false);
let display_name = if is_current && character_data.is_modified {
format!("{}*", char_file.name)
} else {
char_file.name.clone()
};
let base_name = char_file.name.clone();
panel
.spawn((
ButtonBundle {
style: Style {
padding: UiRect::all(Val::Px(8.0)),
..default()
},
background_color: BackgroundColor(FIELD_BG),
..default()
},
CharacterListItem { index: i },
))
.with_children(|item| {
item.spawn((
TextBundle::from_section(
display_name,
TextStyle {
font_size: 14.0,
color: if char_file.is_valid {
TEXT_PRIMARY
} else {
TEXT_MUTED
},
..default()
},
),
CharacterListItemText {
index: i,
base_name,
},
));
});
}
});
}
fn spawn_character_stats_panel(
parent: &mut ChildBuilder,
character_data: &CharacterData,
edit_state: &GroupEditState,
adding_state: &AddingEntryState,
icon_assets: &IconAssets,
) {
parent
.spawn((
NodeBundle {
style: Style {
flex_grow: 1.0,
flex_direction: FlexDirection::Column,
overflow: Overflow::clip_y(),
..default()
},
..default()
},
CharacterStatsPanel,
))
.with_children(|container| {
container
.spawn((
NodeBundle {
style: Style {
flex_direction: FlexDirection::Column,
row_gap: Val::Px(15.0),
padding: UiRect {
left: Val::Px(15.0),
right: Val::Px(15.0),
top: Val::Px(15.0),
bottom: Val::Px(50.0), },
..default()
},
..default()
},
ScrollableContent,
))
.with_children(|panel| {
if let Some(sheet) = &character_data.sheet {
spawn_header_row(panel, sheet, character_data.is_modified, icon_assets);
panel
.spawn(NodeBundle {
style: Style {
flex_direction: FlexDirection::Row,
column_gap: Val::Px(20.0),
flex_wrap: FlexWrap::Wrap,
..default()
},
..default()
})
.with_children(|columns| {
columns
.spawn(NodeBundle {
style: Style {
flex_direction: FlexDirection::Column,
min_width: Val::Px(300.0),
flex_grow: 1.0,
row_gap: Val::Px(15.0),
..default()
},
..default()
})
.with_children(|col| {
spawn_basic_info_group(col, sheet, edit_state, adding_state, icon_assets);
spawn_attributes_group(col, sheet, edit_state, adding_state, icon_assets);
spawn_combat_group(col, sheet, edit_state, adding_state, icon_assets);
});
columns
.spawn(NodeBundle {
style: Style {
flex_direction: FlexDirection::Column,
min_width: Val::Px(300.0),
flex_grow: 1.0,
row_gap: Val::Px(15.0),
..default()
},
..default()
})
.with_children(|col| {
spawn_saving_throws_group(col, sheet, edit_state, adding_state, icon_assets);
spawn_skills_group(col, sheet, edit_state, adding_state, icon_assets);
});
});
} else {
panel.spawn(TextBundle::from_section(
"No character loaded.\nSelect a character from the list or create a new one.",
TextStyle {
font_size: 18.0,
color: TEXT_SECONDARY,
..default()
},
));
}
});
});
}
fn spawn_header_row(
parent: &mut ChildBuilder,
sheet: &CharacterSheet,
is_modified: bool,
icon_assets: &IconAssets,
) {
let save_icon = icon_assets.icons.get(&IconType::Save).cloned();
parent
.spawn(NodeBundle {
style: Style {
flex_direction: FlexDirection::Row,
justify_content: JustifyContent::SpaceBetween,
align_items: AlignItems::Center,
..default()
},
..default()
})
.with_children(|row| {
let title = format!(
"{} {}",
sheet.character.name,
if is_modified { "*" } else { "" }
);
row.spawn(TextBundle::from_section(
title,
TextStyle {
font_size: 24.0,
color: TEXT_PRIMARY,
..default()
},
));
row.spawn((
ButtonBundle {
style: Style {
padding: UiRect::axes(Val::Px(12.0), Val::Px(8.0)),
flex_direction: FlexDirection::Row,
align_items: AlignItems::Center,
column_gap: Val::Px(6.0),
..default()
},
background_color: BackgroundColor(if is_modified {
BUTTON_BG
} else {
Color::srgb(0.3, 0.3, 0.35)
}),
..default()
},
SaveButton,
))
.with_children(|btn| {
if let Some(handle) = save_icon {
btn.spawn(ImageBundle {
image: UiImage::new(handle),
style: Style {
width: Val::Px(16.0),
height: Val::Px(16.0),
..default()
},
..default()
});
}
btn.spawn(TextBundle::from_section(
"Save",
TextStyle {
font_size: 14.0,
color: TEXT_PRIMARY,
..default()
},
));
});
});
}
fn spawn_group_header(
parent: &mut ChildBuilder,
title: &str,
group_type: GroupType,
edit_state: &GroupEditState,
icon_assets: &IconAssets,
) {
let is_editing = edit_state.editing_groups.contains(&group_type);
let edit_icon = if is_editing {
icon_assets.icons.get(&IconType::Check).cloned()
} else {
icon_assets.icons.get(&IconType::Edit).cloned()
};
parent
.spawn(NodeBundle {
style: Style {
flex_direction: FlexDirection::Row,
justify_content: JustifyContent::SpaceBetween,
align_items: AlignItems::Center,
width: Val::Percent(100.0),
..default()
},
..default()
})
.with_children(|header| {
header.spawn(TextBundle::from_section(
title,
TextStyle {
font_size: 16.0,
color: TEXT_PRIMARY,
..default()
},
));
header
.spawn((
ButtonBundle {
style: Style {
padding: UiRect::all(Val::Px(4.0)),
width: Val::Px(24.0),
height: Val::Px(24.0),
justify_content: JustifyContent::Center,
align_items: AlignItems::Center,
..default()
},
background_color: BackgroundColor(if is_editing {
ICON_BUTTON_ACTIVE
} else {
ICON_BUTTON_BG
}),
..default()
},
GroupEditButton {
group_type: group_type.clone(),
},
))
.with_children(|btn| {
if let Some(handle) = edit_icon {
btn.spawn(ImageBundle {
image: UiImage::new(handle),
style: Style {
width: Val::Px(16.0),
height: Val::Px(16.0),
..default()
},
..default()
});
} else {
btn.spawn(TextBundle::from_section(
if is_editing { ICON_CHECK } else { ICON_EDIT },
TextStyle {
font_size: 14.0,
color: if is_editing {
PROFICIENT_COLOR
} else {
TEXT_MUTED
},
..default()
},
));
}
});
});
}
fn spawn_group_add_button(
parent: &mut ChildBuilder,
group_type: GroupType,
adding_state: &AddingEntryState,
icon_assets: &IconAssets,
) {
let is_adding = adding_state.adding_to.as_ref() == Some(&group_type);
let check_icon = icon_assets.icons.get(&IconType::Check).cloned();
let cancel_icon = icon_assets.icons.get(&IconType::Cancel).cloned();
let add_icon = icon_assets.icons.get(&IconType::Add).cloned();
if is_adding {
parent
.spawn(NodeBundle {
style: Style {
width: Val::Percent(100.0),
flex_direction: FlexDirection::Row,
align_items: AlignItems::Center,
column_gap: Val::Px(8.0),
margin: UiRect::top(Val::Px(10.0)),
padding: UiRect::all(Val::Px(8.0)),
border: UiRect::all(Val::Px(2.0)),
..default()
},
background_color: BackgroundColor(Color::srgba(0.15, 0.2, 0.35, 0.9)),
border_color: BorderColor(Color::srgb(0.3, 0.5, 0.7)),
..default()
})
.with_children(|row| {
row.spawn((
NodeBundle {
style: Style {
flex_grow: 1.0,
padding: UiRect::axes(Val::Px(8.0), Val::Px(4.0)),
min_height: Val::Px(24.0),
..default()
},
background_color: BackgroundColor(Color::srgb(0.1, 0.1, 0.15)),
..default()
},
NewEntryInput {
group_type: group_type.clone(),
},
))
.with_children(|input| {
input.spawn(TextBundle::from_section(
if adding_state.new_entry_name.is_empty() {
"Enter name...".to_string()
} else {
format!("{}|", adding_state.new_entry_name)
},
TextStyle {
font_size: 14.0,
color: if adding_state.new_entry_name.is_empty() {
TEXT_MUTED
} else {
Color::srgb(0.9, 0.9, 0.5)
},
..default()
},
));
});
row.spawn((
ButtonBundle {
style: Style {
width: Val::Px(28.0),
height: Val::Px(28.0),
justify_content: JustifyContent::Center,
align_items: AlignItems::Center,
..default()
},
background_color: BackgroundColor(Color::srgb(0.2, 0.5, 0.2)),
..default()
},
NewEntryConfirmButton {
group_type: group_type.clone(),
},
))
.with_children(|btn| {
if let Some(handle) = check_icon.clone() {
btn.spawn(ImageBundle {
image: UiImage::new(handle),
style: Style {
width: Val::Px(18.0),
height: Val::Px(18.0),
..default()
},
..default()
});
} else {
btn.spawn(TextBundle::from_section(
ICON_CHECK,
TextStyle {
font_size: 14.0,
color: Color::srgb(0.5, 1.0, 0.5),
..default()
},
));
}
});
row.spawn((
ButtonBundle {
style: Style {
width: Val::Px(28.0),
height: Val::Px(28.0),
justify_content: JustifyContent::Center,
align_items: AlignItems::Center,
..default()
},
background_color: BackgroundColor(Color::srgb(0.5, 0.2, 0.2)),
..default()
},
NewEntryCancelButton {
group_type: group_type.clone(),
},
))
.with_children(|btn| {
if let Some(handle) = cancel_icon {
btn.spawn(ImageBundle {
image: UiImage::new(handle),
style: Style {
width: Val::Px(18.0),
height: Val::Px(18.0),
..default()
},
..default()
});
} else {
btn.spawn(TextBundle::from_section(
ICON_CANCEL,
TextStyle {
font_size: 14.0,
color: Color::srgb(1.0, 0.5, 0.5),
..default()
},
));
}
});
});
} else {
parent
.spawn((
ButtonBundle {
style: Style {
width: Val::Percent(100.0),
padding: UiRect::all(Val::Px(10.0)),
flex_direction: FlexDirection::Row,
justify_content: JustifyContent::Center,
align_items: AlignItems::Center,
column_gap: Val::Px(6.0),
margin: UiRect::top(Val::Px(10.0)),
border: UiRect::all(Val::Px(2.0)),
..default()
},
background_color: BackgroundColor(Color::srgba(0.15, 0.35, 0.15, 0.8)),
border_color: BorderColor(Color::srgb(0.4, 0.7, 0.4)),
..default()
},
GroupAddButton { group_type },
))
.with_children(|btn| {
if let Some(handle) = add_icon {
btn.spawn(ImageBundle {
image: UiImage::new(handle),
style: Style {
width: Val::Px(16.0),
height: Val::Px(16.0),
..default()
},
..default()
});
}
btn.spawn(TextBundle::from_section(
"Add New",
TextStyle {
font_size: 14.0,
color: Color::srgb(0.5, 0.9, 0.5),
..default()
},
));
});
}
}
fn spawn_delete_button(
parent: &mut ChildBuilder,
group_type: GroupType,
entry_id: &str,
icon_assets: &IconAssets,
) {
let delete_icon = icon_assets.icons.get(&IconType::Delete).cloned();
parent
.spawn((
ButtonBundle {
style: Style {
width: Val::Px(20.0),
height: Val::Px(20.0),
justify_content: JustifyContent::Center,
align_items: AlignItems::Center,
margin: UiRect::left(Val::Px(4.0)),
..default()
},
background_color: BackgroundColor(Color::srgba(0.5, 0.2, 0.2, 0.8)),
..default()
},
DeleteEntryButton {
group_type,
entry_id: entry_id.to_string(),
},
))
.with_children(|btn| {
if let Some(handle) = delete_icon {
btn.spawn(ImageBundle {
image: UiImage::new(handle),
style: Style {
width: Val::Px(14.0),
height: Val::Px(14.0),
..default()
},
..default()
});
} else {
btn.spawn(TextBundle::from_section(
ICON_DELETE,
TextStyle {
font_size: 12.0,
color: Color::srgb(1.0, 0.5, 0.5),
..default()
},
));
}
});
}
fn spawn_custom_field_row(
parent: &mut ChildBuilder,
field_name: &str,
field_value: &str,
group_type: GroupType,
is_editing: bool,
icon_assets: &IconAssets,
) {
parent
.spawn(NodeBundle {
style: Style {
flex_direction: FlexDirection::Row,
justify_content: JustifyContent::SpaceBetween,
align_items: AlignItems::Center,
padding: UiRect::vertical(Val::Px(2.0)),
..default()
},
..default()
})
.with_children(|row| {
row.spawn(TextBundle::from_section(
field_name,
TextStyle {
font_size: 14.0,
color: TEXT_SECONDARY,
..default()
},
));
row.spawn(NodeBundle {
style: Style {
flex_direction: FlexDirection::Row,
align_items: AlignItems::Center,
column_gap: Val::Px(4.0),
..default()
},
..default()
})
.with_children(|right| {
right.spawn(TextBundle::from_section(
field_value,
TextStyle {
font_size: 14.0,
color: TEXT_PRIMARY,
..default()
},
));
if is_editing {
spawn_delete_button(right, group_type, field_name, icon_assets);
}
});
});
}
fn spawn_basic_info_group(
parent: &mut ChildBuilder,
sheet: &CharacterSheet,
edit_state: &GroupEditState,
adding_state: &AddingEntryState,
icon_assets: &IconAssets,
) {
let group_type = GroupType::BasicInfo;
let is_editing = edit_state.editing_groups.contains(&group_type);
parent
.spawn((
NodeBundle {
style: Style {
flex_direction: FlexDirection::Column,
padding: UiRect::all(Val::Px(10.0)),
row_gap: Val::Px(8.0),
..default()
},
background_color: BackgroundColor(GROUP_BG),
..default()
},
StatGroup {
name: "Basic Info".to_string(),
group_type: group_type.clone(),
},
))
.with_children(|group| {
spawn_group_header(
group,
"Basic Info",
group_type.clone(),
edit_state,
icon_assets,
);
spawn_stat_field(
group,
"Name",
&sheet.character.name,
EditingField::CharacterName,
false,
is_editing,
Some(group_type.clone()),
Some("name"),
icon_assets,
);
spawn_stat_field(
group,
"Class",
&sheet.character.class,
EditingField::CharacterClass,
false,
is_editing,
Some(group_type.clone()),
Some("class"),
icon_assets,
);
spawn_stat_field(
group,
"Race",
&sheet.character.race,
EditingField::CharacterRace,
false,
is_editing,
Some(group_type.clone()),
Some("race"),
icon_assets,
);
spawn_stat_field(
group,
"Level",
&sheet.character.level.to_string(),
EditingField::CharacterLevel,
true,
is_editing,
Some(group_type.clone()),
Some("level"),
icon_assets,
);
if let Some(subclass) = &sheet.character.subclass {
spawn_readonly_field(group, "Subclass", subclass);
}
if let Some(background) = &sheet.character.background {
spawn_readonly_field(group, "Background", background);
}
if let Some(alignment) = &sheet.character.alignment {
spawn_readonly_field(group, "Alignment", alignment);
}
for (field_name, field_value) in sheet.custom_basic_info.iter() {
spawn_custom_field_row(
group,
field_name,
field_value,
GroupType::BasicInfo,
is_editing,
icon_assets,
);
}
if is_editing {
spawn_group_add_button(group, group_type, adding_state, icon_assets);
}
});
}
fn spawn_attributes_group(
parent: &mut ChildBuilder,
sheet: &CharacterSheet,
edit_state: &GroupEditState,
adding_state: &AddingEntryState,
icon_assets: &IconAssets,
) {
let group_type = GroupType::Attributes;
let is_editing = edit_state.editing_groups.contains(&group_type);
parent
.spawn((
NodeBundle {
style: Style {
flex_direction: FlexDirection::Column,
padding: UiRect::all(Val::Px(10.0)),
row_gap: Val::Px(8.0),
..default()
},
background_color: BackgroundColor(GROUP_BG),
..default()
},
StatGroup {
name: "Attributes".to_string(),
group_type: group_type.clone(),
},
))
.with_children(|group| {
spawn_group_header(
group,
"Attributes",
group_type.clone(),
edit_state,
icon_assets,
);
let attrs = [
(
"Strength",
sheet.attributes.strength,
sheet.modifiers.strength,
EditingField::AttributeStrength,
),
(
"Dexterity",
sheet.attributes.dexterity,
sheet.modifiers.dexterity,
EditingField::AttributeDexterity,
),
(
"Constitution",
sheet.attributes.constitution,
sheet.modifiers.constitution,
EditingField::AttributeConstitution,
),
(
"Intelligence",
sheet.attributes.intelligence,
sheet.modifiers.intelligence,
EditingField::AttributeIntelligence,
),
(
"Wisdom",
sheet.attributes.wisdom,
sheet.modifiers.wisdom,
EditingField::AttributeWisdom,
),
(
"Charisma",
sheet.attributes.charisma,
sheet.modifiers.charisma,
EditingField::AttributeCharisma,
),
];
for (name, score, modifier, field) in attrs {
spawn_attribute_row(group, name, score, modifier, field, is_editing, icon_assets);
}
for (attr_name, attr_score) in sheet.custom_attributes.iter() {
let modifier = Attributes::calculate_modifier(*attr_score);
spawn_custom_attribute_row(
group,
attr_name,
*attr_score,
modifier,
is_editing,
icon_assets,
);
}
if is_editing {
spawn_group_add_button(group, group_type, adding_state, icon_assets);
}
});
}
fn spawn_attribute_row(
parent: &mut ChildBuilder,
name: &str,
score: i32,
modifier: i32,
field: EditingField,
is_editing: bool,
icon_assets: &IconAssets,
) {
parent
.spawn(NodeBundle {
style: Style {
flex_direction: FlexDirection::Row,
justify_content: JustifyContent::SpaceBetween,
align_items: AlignItems::Center,
..default()
},
..default()
})
.with_children(|row| {
row.spawn(TextBundle::from_section(
name,
TextStyle {
font_size: 14.0,
color: TEXT_SECONDARY,
..default()
},
));
row.spawn(NodeBundle {
style: Style {
flex_direction: FlexDirection::Row,
column_gap: Val::Px(10.0),
align_items: AlignItems::Center,
..default()
},
..default()
})
.with_children(|values| {
values
.spawn((
NodeBundle {
style: Style {
padding: UiRect::axes(Val::Px(8.0), Val::Px(4.0)),
min_width: Val::Px(40.0),
justify_content: JustifyContent::Center,
..default()
},
background_color: BackgroundColor(if is_editing {
Color::srgba(0.12, 0.12, 0.15, 0.5)
} else {
FIELD_BG
}),
..default()
},
StatField {
field,
is_numeric: true,
},
))
.with_children(|field_node| {
field_node.spawn(TextBundle::from_section(
score.to_string(),
TextStyle {
font_size: 14.0,
color: if is_editing { TEXT_MUTED } else { TEXT_PRIMARY },
..default()
},
));
});
let mod_str = if modifier >= 0 {
format!("+{}", modifier)
} else {
modifier.to_string()
};
values.spawn(TextBundle::from_section(
format!("({})", mod_str),
TextStyle {
font_size: 14.0,
color: TEXT_MUTED,
..default()
},
));
});
if is_editing {
spawn_delete_button(row, GroupType::Attributes, name, icon_assets);
}
});
}
fn spawn_custom_attribute_row(
parent: &mut ChildBuilder,
name: &str,
score: i32,
modifier: i32,
is_editing: bool,
icon_assets: &IconAssets,
) {
parent
.spawn(NodeBundle {
style: Style {
flex_direction: FlexDirection::Row,
justify_content: JustifyContent::SpaceBetween,
align_items: AlignItems::Center,
..default()
},
..default()
})
.with_children(|row| {
row.spawn(TextBundle::from_section(
name,
TextStyle {
font_size: 14.0,
color: TEXT_SECONDARY,
..default()
},
));
row.spawn(NodeBundle {
style: Style {
flex_direction: FlexDirection::Row,
column_gap: Val::Px(10.0),
align_items: AlignItems::Center,
..default()
},
..default()
})
.with_children(|values| {
values.spawn(TextBundle::from_section(
score.to_string(),
TextStyle {
font_size: 14.0,
color: TEXT_PRIMARY,
..default()
},
));
let mod_str = if modifier >= 0 {
format!("+{}", modifier)
} else {
modifier.to_string()
};
values.spawn(TextBundle::from_section(
format!("({})", mod_str),
TextStyle {
font_size: 14.0,
color: TEXT_MUTED,
..default()
},
));
if is_editing {
spawn_delete_button(values, GroupType::Attributes, name, icon_assets);
}
});
});
}
fn spawn_combat_group(
parent: &mut ChildBuilder,
sheet: &CharacterSheet,
edit_state: &GroupEditState,
adding_state: &AddingEntryState,
icon_assets: &IconAssets,
) {
let group_type = GroupType::Combat;
let is_editing = edit_state.editing_groups.contains(&group_type);
parent
.spawn((
NodeBundle {
style: Style {
flex_direction: FlexDirection::Column,
padding: UiRect::all(Val::Px(10.0)),
row_gap: Val::Px(8.0),
..default()
},
background_color: BackgroundColor(GROUP_BG),
..default()
},
StatGroup {
name: "Combat".to_string(),
group_type: group_type.clone(),
},
))
.with_children(|group| {
spawn_group_header(group, "Combat", group_type.clone(), edit_state, icon_assets);
spawn_stat_field(
group,
"Armor Class",
&sheet.combat.armor_class.to_string(),
EditingField::ArmorClass,
true,
is_editing,
Some(group_type.clone()),
Some("armor_class"),
icon_assets,
);
spawn_stat_field(
group,
"Initiative",
&format!(
"{}{}",
if sheet.combat.initiative >= 0 {
"+"
} else {
""
},
sheet.combat.initiative
),
EditingField::Initiative,
true,
is_editing,
Some(group_type.clone()),
Some("initiative"),
icon_assets,
);
spawn_stat_field(
group,
"Speed",
&format!("{} ft", sheet.combat.speed),
EditingField::Speed,
true,
is_editing,
Some(group_type.clone()),
Some("speed"),
icon_assets,
);
spawn_stat_field(
group,
"Proficiency",
&format!("+{}", sheet.proficiency_bonus),
EditingField::ProficiencyBonus,
true,
is_editing,
Some(group_type.clone()),
Some("proficiency_bonus"),
icon_assets,
);
if let Some(hp) = &sheet.combat.hit_points {
spawn_hp_field(group, hp, is_editing);
}
for (stat_name, stat_value) in sheet.custom_combat.iter() {
spawn_custom_field_row(
group,
stat_name,
stat_value,
GroupType::Combat,
is_editing,
icon_assets,
);
}
if is_editing {
spawn_group_add_button(group, group_type, adding_state, icon_assets);
}
});
}
fn spawn_hp_field(parent: &mut ChildBuilder, hp: &HitPoints, is_editing: bool) {
parent
.spawn(NodeBundle {
style: Style {
flex_direction: FlexDirection::Row,
justify_content: JustifyContent::SpaceBetween,
align_items: AlignItems::Center,
..default()
},
..default()
})
.with_children(|row| {
row.spawn(TextBundle::from_section(
"Hit Points",
TextStyle {
font_size: 14.0,
color: TEXT_SECONDARY,
..default()
},
));
row.spawn(NodeBundle {
style: Style {
flex_direction: FlexDirection::Row,
column_gap: Val::Px(5.0),
align_items: AlignItems::Center,
..default()
},
..default()
})
.with_children(|values| {
let hp_current_field = EditingField::HitPointsCurrent;
values
.spawn((
ButtonBundle {
style: Style {
padding: UiRect::axes(Val::Px(8.0), Val::Px(4.0)),
min_width: Val::Px(40.0),
justify_content: JustifyContent::Center,
..default()
},
background_color: BackgroundColor(if is_editing {
Color::srgba(0.12, 0.12, 0.15, 0.5)
} else {
FIELD_BG
}),
..default()
},
StatField {
field: hp_current_field.clone(),
is_numeric: true,
},
))
.with_children(|field| {
field.spawn((
TextBundle::from_section(
hp.current.to_string(),
TextStyle {
font_size: 14.0,
color: if is_editing {
TEXT_MUTED
} else if hp.current < hp.maximum / 2 {
Color::srgb(0.9, 0.4, 0.4)
} else {
TEXT_PRIMARY
},
..default()
},
),
StatFieldValue {
field: hp_current_field,
},
));
});
values.spawn(TextBundle::from_section(
"/",
TextStyle {
font_size: 14.0,
color: TEXT_MUTED,
..default()
},
));
let hp_max_field = EditingField::HitPointsMaximum;
values
.spawn((
ButtonBundle {
style: Style {
padding: UiRect::axes(Val::Px(8.0), Val::Px(4.0)),
min_width: Val::Px(40.0),
justify_content: JustifyContent::Center,
..default()
},
background_color: BackgroundColor(if is_editing {
Color::srgba(0.12, 0.12, 0.15, 0.5)
} else {
FIELD_BG
}),
..default()
},
StatField {
field: hp_max_field.clone(),
is_numeric: true,
},
))
.with_children(|field| {
field.spawn((
TextBundle::from_section(
hp.maximum.to_string(),
TextStyle {
font_size: 14.0,
color: if is_editing { TEXT_MUTED } else { TEXT_PRIMARY },
..default()
},
),
StatFieldValue {
field: hp_max_field,
},
));
});
if hp.temporary > 0 {
values.spawn(TextBundle::from_section(
format!("(+{} temp)", hp.temporary),
TextStyle {
font_size: 12.0,
color: Color::srgb(0.4, 0.7, 0.9),
..default()
},
));
}
});
});
}
fn spawn_saving_throws_group(
parent: &mut ChildBuilder,
sheet: &CharacterSheet,
edit_state: &GroupEditState,
adding_state: &AddingEntryState,
icon_assets: &IconAssets,
) {
let group_type = GroupType::SavingThrows;
let is_editing = edit_state.editing_groups.contains(&group_type);
parent
.spawn((
NodeBundle {
style: Style {
flex_direction: FlexDirection::Column,
padding: UiRect::all(Val::Px(10.0)),
row_gap: Val::Px(6.0),
..default()
},
background_color: BackgroundColor(GROUP_BG),
..default()
},
StatGroup {
name: "Saving Throws".to_string(),
group_type: group_type.clone(),
},
))
.with_children(|group| {
spawn_group_header(
group,
"Saving Throws",
group_type.clone(),
edit_state,
icon_assets,
);
let abilities = [
"strength",
"dexterity",
"constitution",
"intelligence",
"wisdom",
"charisma",
];
for ability in abilities {
if let Some(save) = sheet.saving_throws.get(ability) {
spawn_saving_throw_row(group, ability, save, is_editing, icon_assets);
}
}
for (save_name, save) in sheet.saving_throws.iter() {
if !abilities.contains(&save_name.as_str()) {
spawn_saving_throw_row(group, save_name, save, is_editing, icon_assets);
}
}
if is_editing {
spawn_group_add_button(group, group_type, adding_state, icon_assets);
}
});
}
fn spawn_saving_throw_row(
parent: &mut ChildBuilder,
ability: &str,
save: &SavingThrow,
is_editing: bool,
icon_assets: &IconAssets,
) {
let ability_owned = ability.to_string();
parent
.spawn((
NodeBundle {
style: Style {
flex_direction: FlexDirection::Row,
justify_content: JustifyContent::SpaceBetween,
align_items: AlignItems::Center,
padding: UiRect::vertical(Val::Px(2.0)),
..default()
},
..default()
},
SavingThrowRow {
ability: ability_owned.clone(),
},
))
.with_children(|row| {
row.spawn(NodeBundle {
style: Style {
flex_direction: FlexDirection::Row,
column_gap: Val::Px(8.0),
align_items: AlignItems::Center,
..default()
},
..default()
})
.with_children(|left| {
let has_value = save.modifier != 0;
left.spawn((
NodeBundle {
style: Style {
width: Val::Px(14.0),
height: Val::Px(14.0),
border: UiRect::all(Val::Px(1.0)),
justify_content: JustifyContent::Center,
align_items: AlignItems::Center,
..default()
},
background_color: BackgroundColor(if has_value {
PROFICIENT_COLOR
} else {
Color::NONE
}),
border_color: BorderColor(TEXT_MUTED),
..default()
},
ProficiencyCheckbox {
target: ProficiencyTarget::SavingThrow(ability_owned.clone()),
},
));
let display_name = format!(
"{}{}",
ability.chars().next().unwrap().to_uppercase(),
&ability[1..3]
);
let has_value = save.modifier != 0;
let label_field = EditingField::SavingThrowLabel(ability_owned.clone());
if is_editing {
left.spawn((
ButtonBundle {
style: Style {
padding: UiRect::axes(Val::Px(4.0), Val::Px(2.0)),
border: UiRect::all(Val::Px(1.0)),
..default()
},
background_color: BackgroundColor(Color::srgba(0.2, 0.2, 0.25, 0.5)),
border_color: BorderColor(Color::srgb(0.3, 0.3, 0.4)),
..default()
},
EditableLabelButton {
field: label_field.clone(),
current_name: ability_owned.clone(),
},
))
.with_children(|btn| {
btn.spawn((
TextBundle::from_section(
display_name.clone(),
TextStyle {
font_size: 14.0,
color: if has_value {
PROFICIENT_COLOR
} else {
TEXT_SECONDARY
},
..default()
},
),
EditableLabelText { field: label_field },
));
});
} else {
left.spawn(TextBundle::from_section(
display_name,
TextStyle {
font_size: 14.0,
color: if has_value {
PROFICIENT_COLOR
} else {
TEXT_SECONDARY
},
..default()
},
));
}
});
let mod_str = if save.modifier >= 0 {
format!("+{}", save.modifier)
} else {
save.modifier.to_string()
};
let field = EditingField::SavingThrow(ability_owned.clone());
let field_clone = field.clone();
row.spawn((
ButtonBundle {
style: Style {
padding: UiRect::axes(Val::Px(6.0), Val::Px(2.0)),
min_width: Val::Px(40.0),
justify_content: JustifyContent::Center,
..default()
},
background_color: BackgroundColor(if is_editing {
Color::srgba(0.12, 0.12, 0.15, 0.5) } else {
FIELD_BG
}),
..default()
},
StatField {
field,
is_numeric: true,
},
))
.with_children(|btn| {
btn.spawn((
TextBundle::from_section(
mod_str,
TextStyle {
font_size: 14.0,
color: if is_editing { TEXT_MUTED } else { TEXT_PRIMARY },
..default()
},
),
StatFieldValue { field: field_clone },
));
});
if is_editing {
spawn_delete_button(row, GroupType::SavingThrows, &ability_owned, icon_assets);
}
});
}
fn spawn_skills_group(
parent: &mut ChildBuilder,
sheet: &CharacterSheet,
edit_state: &GroupEditState,
adding_state: &AddingEntryState,
icon_assets: &IconAssets,
) {
let group_type = GroupType::Skills;
let is_editing = edit_state.editing_groups.contains(&group_type);
parent
.spawn((
NodeBundle {
style: Style {
flex_direction: FlexDirection::Column,
padding: UiRect::all(Val::Px(10.0)),
row_gap: Val::Px(4.0),
..default()
},
background_color: BackgroundColor(GROUP_BG),
..default()
},
StatGroup {
name: "Skills".to_string(),
group_type: group_type.clone(),
},
))
.with_children(|group| {
spawn_group_header(group, "Skills", group_type.clone(), edit_state, icon_assets);
let mut skills: Vec<_> = sheet.skills.iter().collect();
skills.sort_by(|a, b| a.0.to_lowercase().cmp(&b.0.to_lowercase()));
for (skill_name, skill) in skills {
spawn_skill_row(group, skill_name, skill, is_editing, icon_assets);
}
if is_editing {
spawn_group_add_button(group, group_type, adding_state, icon_assets);
}
});
}
fn spawn_skill_row(
parent: &mut ChildBuilder,
skill_name: &str,
skill: &Skill,
is_editing: bool,
icon_assets: &IconAssets,
) {
let display_name = skill_name
.chars()
.enumerate()
.map(|(i, c)| {
if i == 0 {
c.to_uppercase().next().unwrap()
} else if c.is_uppercase() {
format!(" {}", c).chars().next().unwrap()
} else {
c
}
})
.collect::<String>();
let skill_name_owned = skill_name.to_string();
parent
.spawn((
NodeBundle {
style: Style {
flex_direction: FlexDirection::Row,
justify_content: JustifyContent::SpaceBetween,
align_items: AlignItems::Center,
padding: UiRect::vertical(Val::Px(2.0)),
..default()
},
..default()
},
SkillRow {
skill_name: skill_name_owned.clone(),
},
))
.with_children(|row| {
row.spawn(NodeBundle {
style: Style {
flex_direction: FlexDirection::Row,
column_gap: Val::Px(6.0),
align_items: AlignItems::Center,
..default()
},
..default()
})
.with_children(|left| {
let has_value = skill.modifier != 0;
left.spawn((
NodeBundle {
style: Style {
width: Val::Px(14.0),
height: Val::Px(14.0),
border: UiRect::all(Val::Px(1.0)),
justify_content: JustifyContent::Center,
align_items: AlignItems::Center,
..default()
},
background_color: BackgroundColor(if has_value {
PROFICIENT_COLOR
} else {
Color::NONE
}),
border_color: BorderColor(TEXT_MUTED),
..default()
},
ProficiencyCheckbox {
target: ProficiencyTarget::Skill(skill_name_owned.clone()),
},
));
let has_value = skill.modifier != 0;
let label_field = EditingField::SkillLabel(skill_name_owned.clone());
if is_editing {
left.spawn((
ButtonBundle {
style: Style {
padding: UiRect::axes(Val::Px(4.0), Val::Px(2.0)),
border: UiRect::all(Val::Px(1.0)),
..default()
},
background_color: BackgroundColor(Color::srgba(0.2, 0.2, 0.25, 0.5)),
border_color: BorderColor(Color::srgb(0.3, 0.3, 0.4)),
..default()
},
EditableLabelButton {
field: label_field.clone(),
current_name: skill_name_owned.clone(),
},
))
.with_children(|btn| {
btn.spawn((
TextBundle::from_section(
display_name.clone(),
TextStyle {
font_size: 13.0,
color: if has_value {
PROFICIENT_COLOR
} else {
TEXT_SECONDARY
},
..default()
},
),
EditableLabelText { field: label_field },
));
});
} else {
left.spawn(TextBundle::from_section(
display_name,
TextStyle {
font_size: 13.0,
color: if has_value {
PROFICIENT_COLOR
} else {
TEXT_SECONDARY
},
..default()
},
));
}
});
let mod_str = if skill.modifier >= 0 {
format!("+{}", skill.modifier)
} else {
skill.modifier.to_string()
};
let field = EditingField::Skill(skill_name_owned.clone());
let field_clone = field.clone();
row.spawn((
ButtonBundle {
style: Style {
padding: UiRect::axes(Val::Px(6.0), Val::Px(2.0)),
min_width: Val::Px(40.0),
justify_content: JustifyContent::Center,
..default()
},
background_color: BackgroundColor(if is_editing {
Color::srgba(0.12, 0.12, 0.15, 0.5) } else {
FIELD_BG
}),
..default()
},
StatField {
field,
is_numeric: true,
},
))
.with_children(|btn| {
btn.spawn((
TextBundle::from_section(
mod_str,
TextStyle {
font_size: 13.0,
color: if is_editing { TEXT_MUTED } else { TEXT_PRIMARY },
..default()
},
),
StatFieldValue { field: field_clone },
));
});
if is_editing {
spawn_delete_button(row, GroupType::Skills, &skill_name_owned, icon_assets);
}
});
}
#[allow(clippy::too_many_arguments)]
fn spawn_stat_field(
parent: &mut ChildBuilder,
label: &str,
value: &str,
field: EditingField,
is_numeric: bool,
is_editing: bool,
group_type: Option<GroupType>,
entry_id: Option<&str>,
icon_assets: &IconAssets,
) {
let field_clone = field.clone();
parent
.spawn(NodeBundle {
style: Style {
flex_direction: FlexDirection::Row,
justify_content: JustifyContent::SpaceBetween,
align_items: AlignItems::Center,
..default()
},
..default()
})
.with_children(|row| {
row.spawn(TextBundle::from_section(
label,
TextStyle {
font_size: 14.0,
color: TEXT_SECONDARY,
..default()
},
));
row.spawn((
ButtonBundle {
style: Style {
padding: UiRect::axes(Val::Px(8.0), Val::Px(4.0)),
min_width: Val::Px(80.0),
..default()
},
background_color: BackgroundColor(if is_editing {
Color::srgba(0.12, 0.12, 0.15, 0.5)
} else {
FIELD_BG
}),
..default()
},
StatField { field, is_numeric },
))
.with_children(|field_node| {
field_node.spawn((
TextBundle::from_section(
value,
TextStyle {
font_size: 14.0,
color: if is_editing { TEXT_MUTED } else { TEXT_PRIMARY },
..default()
},
),
StatFieldValue { field: field_clone },
));
});
if is_editing {
if let (Some(gt), Some(eid)) = (group_type, entry_id) {
spawn_delete_button(row, gt, eid, icon_assets);
}
}
});
}
fn spawn_readonly_field(parent: &mut ChildBuilder, label: &str, value: &str) {
parent
.spawn(NodeBundle {
style: Style {
flex_direction: FlexDirection::Row,
justify_content: JustifyContent::SpaceBetween,
align_items: AlignItems::Center,
..default()
},
..default()
})
.with_children(|row| {
row.spawn(TextBundle::from_section(
label,
TextStyle {
font_size: 14.0,
color: TEXT_SECONDARY,
..default()
},
));
row.spawn(TextBundle::from_section(
value,
TextStyle {
font_size: 14.0,
color: TEXT_MUTED,
..default()
},
));
});
}
pub fn handle_tab_clicks(
mut interaction_query: Query<
(
&Interaction,
&TabButton,
&mut BackgroundColor,
&mut BorderColor,
),
Changed<Interaction>,
>,
mut ui_state: ResMut<UiState>,
) {
let mut new_active_tab = None;
for (interaction, tab_button, mut bg, mut border) in interaction_query.iter_mut() {
match *interaction {
Interaction::Pressed => {
new_active_tab = Some(tab_button.tab);
*bg = BackgroundColor(TAB_ACTIVE_BG);
*border = BorderColor(Color::srgb(0.4, 0.6, 0.8));
}
Interaction::Hovered => {
if ui_state.active_tab != tab_button.tab {
*bg = BackgroundColor(TAB_HOVER_BG);
}
}
Interaction::None => {
if ui_state.active_tab == tab_button.tab {
*bg = BackgroundColor(TAB_ACTIVE_BG);
*border = BorderColor(Color::srgb(0.4, 0.6, 0.8));
} else {
*bg = BackgroundColor(TAB_INACTIVE_BG);
*border = BorderColor(Color::srgb(0.2, 0.2, 0.3));
}
}
}
}
if let Some(tab) = new_active_tab {
ui_state.active_tab = tab;
}
}
pub fn update_tab_styles(
ui_state: Res<UiState>,
mut tab_buttons: Query<(&TabButton, &mut BackgroundColor, &mut BorderColor)>,
) {
if !ui_state.is_changed() {
return;
}
for (tab_button, mut bg, mut border) in tab_buttons.iter_mut() {
if tab_button.tab == ui_state.active_tab {
*bg = BackgroundColor(TAB_ACTIVE_BG);
*border = BorderColor(Color::srgb(0.4, 0.6, 0.8));
} else {
*bg = BackgroundColor(TAB_INACTIVE_BG);
*border = BorderColor(Color::srgb(0.2, 0.2, 0.3));
}
}
}
#[allow(clippy::type_complexity)]
pub fn update_tab_visibility(
ui_state: Res<UiState>,
mut dice_roller_query: Query<
&mut Visibility,
(
With<DiceRollerRoot>,
Without<CharacterScreenRoot>,
Without<DndInfoScreenRoot>,
Without<ContributorsScreenRoot>,
),
>,
mut char_screen_query: Query<
&mut Visibility,
(
With<CharacterScreenRoot>,
Without<DiceRollerRoot>,
Without<DndInfoScreenRoot>,
Without<ContributorsScreenRoot>,
),
>,
mut info_screen_query: Query<
&mut Visibility,
(
With<DndInfoScreenRoot>,
Without<DiceRollerRoot>,
Without<CharacterScreenRoot>,
Without<ContributorsScreenRoot>,
),
>,
mut contributors_screen_query: Query<
&mut Visibility,
(
With<ContributorsScreenRoot>,
Without<DiceRollerRoot>,
Without<CharacterScreenRoot>,
Without<DndInfoScreenRoot>,
),
>,
) {
if !ui_state.is_changed() {
return;
}
for mut vis in dice_roller_query.iter_mut() {
*vis = if ui_state.active_tab == AppTab::DiceRoller {
Visibility::Visible
} else {
Visibility::Hidden
};
}
for mut vis in char_screen_query.iter_mut() {
*vis = if ui_state.active_tab == AppTab::CharacterSheet {
Visibility::Visible
} else {
Visibility::Hidden
};
}
for mut vis in info_screen_query.iter_mut() {
*vis = if ui_state.active_tab == AppTab::DndInfo {
Visibility::Visible
} else {
Visibility::Hidden
};
}
for mut vis in contributors_screen_query.iter_mut() {
*vis = if ui_state.active_tab == AppTab::Contributors {
Visibility::Visible
} else {
Visibility::Hidden
};
}
}
pub fn handle_scroll_input(
mut mouse_wheel: EventReader<bevy::input::mouse::MouseWheel>,
mut scrollable_query: Query<(&mut Style, &Node, &Parent), With<ScrollableContent>>,
parent_query: Query<&Node>,
ui_state: Res<UiState>,
mut info_scroll_query: Query<
(&mut Style, &Node, &Parent),
(With<InfoScrollContent>, Without<ScrollableContent>),
>,
) {
if ui_state.active_tab != AppTab::CharacterSheet && ui_state.active_tab != AppTab::DndInfo {
return;
}
let scroll_speed = 30.0;
let mut scroll_delta = 0.0;
for event in mouse_wheel.read() {
scroll_delta += event.y * scroll_speed;
}
if scroll_delta == 0.0 {
return;
}
if ui_state.active_tab == AppTab::CharacterSheet {
for (mut style, node, parent) in scrollable_query.iter_mut() {
if let Ok(parent_node) = parent_query.get(parent.get()) {
let content_height = node.size().y;
let container_height = parent_node.size().y;
let max_scroll = (content_height - container_height).max(0.0);
let current_top = match style.top {
Val::Px(px) => px,
_ => 0.0,
};
let new_top = (current_top + scroll_delta).clamp(-max_scroll, 0.0);
style.top = Val::Px(new_top);
}
}
}
if ui_state.active_tab == AppTab::DndInfo {
for (mut style, node, parent) in info_scroll_query.iter_mut() {
if let Ok(parent_node) = parent_query.get(parent.get()) {
let content_height = node.size().y;
let container_height = parent_node.size().y;
let max_scroll = (content_height - container_height).max(0.0);
let current_top = match style.top {
Val::Px(px) => px,
_ => 0.0,
};
let new_top = (current_top + scroll_delta).clamp(-max_scroll, 0.0);
style.top = Val::Px(new_top);
}
}
}
}
pub fn handle_stat_field_click(
interaction_query: Query<(&Interaction, &StatField), Changed<Interaction>>,
mut text_input: ResMut<TextInputState>,
character_data: Res<CharacterData>,
edit_state: Res<GroupEditState>,
) {
for (interaction, stat_field) in interaction_query.iter() {
if *interaction == Interaction::Pressed {
let field_group = match &stat_field.field {
EditingField::CharacterName
| EditingField::CharacterClass
| EditingField::CharacterRace
| EditingField::CharacterLevel => Some(GroupType::BasicInfo),
EditingField::AttributeStrength
| EditingField::AttributeDexterity
| EditingField::AttributeConstitution
| EditingField::AttributeIntelligence
| EditingField::AttributeWisdom
| EditingField::AttributeCharisma => Some(GroupType::Attributes),
EditingField::ArmorClass
| EditingField::Initiative
| EditingField::Speed
| EditingField::ProficiencyBonus
| EditingField::HitPointsCurrent
| EditingField::HitPointsMaximum => Some(GroupType::Combat),
EditingField::SavingThrow(_) | EditingField::SavingThrowLabel(_) => {
Some(GroupType::SavingThrows)
}
EditingField::Skill(_) | EditingField::SkillLabel(_) => Some(GroupType::Skills),
};
if let Some(group) = field_group {
if edit_state.editing_groups.contains(&group) {
continue;
}
}
let current_value = get_field_value(&character_data, &stat_field.field);
text_input.active_field = Some(stat_field.field.clone());
text_input.current_text = current_value;
text_input.cursor_position = text_input.current_text.len();
}
}
}
pub fn handle_label_click(
interaction_query: Query<(&Interaction, &EditableLabelButton), Changed<Interaction>>,
mut text_input: ResMut<TextInputState>,
) {
for (interaction, label_button) in interaction_query.iter() {
if *interaction == Interaction::Pressed {
text_input.active_field = Some(label_button.field.clone());
text_input.current_text = label_button.current_name.clone();
text_input.cursor_position = text_input.current_text.len();
}
}
}
fn get_field_value(character_data: &CharacterData, field: &EditingField) -> String {
let Some(sheet) = &character_data.sheet else {
return String::new();
};
match field {
EditingField::CharacterName => sheet.character.name.clone(),
EditingField::CharacterClass => sheet.character.class.clone(),
EditingField::CharacterRace => sheet.character.race.clone(),
EditingField::CharacterLevel => sheet.character.level.to_string(),
EditingField::AttributeStrength => sheet.attributes.strength.to_string(),
EditingField::AttributeDexterity => sheet.attributes.dexterity.to_string(),
EditingField::AttributeConstitution => sheet.attributes.constitution.to_string(),
EditingField::AttributeIntelligence => sheet.attributes.intelligence.to_string(),
EditingField::AttributeWisdom => sheet.attributes.wisdom.to_string(),
EditingField::AttributeCharisma => sheet.attributes.charisma.to_string(),
EditingField::ArmorClass => sheet.combat.armor_class.to_string(),
EditingField::Initiative => format_modifier(sheet.combat.initiative),
EditingField::Speed => sheet.combat.speed.to_string(),
EditingField::ProficiencyBonus => format_modifier(sheet.proficiency_bonus),
EditingField::HitPointsCurrent => sheet
.combat
.hit_points
.as_ref()
.map(|hp| hp.current.to_string())
.unwrap_or_default(),
EditingField::HitPointsMaximum => sheet
.combat
.hit_points
.as_ref()
.map(|hp| hp.maximum.to_string())
.unwrap_or_default(),
EditingField::Skill(name) => sheet
.skills
.get(name)
.map(|s| format_modifier(s.modifier))
.unwrap_or_default(),
EditingField::SavingThrow(name) => sheet
.saving_throws
.get(name)
.map(|s| format_modifier(s.modifier))
.unwrap_or_default(),
EditingField::SkillLabel(name) => name.clone(),
EditingField::SavingThrowLabel(name) => name.clone(),
}
}
fn format_modifier(value: i32) -> String {
if value >= 0 {
format!("+{}", value)
} else {
value.to_string() }
}
fn parse_modifier(value: &str) -> Option<i32> {
let trimmed = value.trim();
if let Some(rest) = trimmed.strip_prefix('+') {
rest.parse().ok()
} else {
trimmed.parse().ok()
}
}
fn get_tab_order(character_data: &CharacterData) -> Vec<EditingField> {
let mut fields = vec![
EditingField::CharacterName,
EditingField::CharacterClass,
EditingField::CharacterRace,
EditingField::CharacterLevel,
EditingField::AttributeStrength,
EditingField::AttributeDexterity,
EditingField::AttributeConstitution,
EditingField::AttributeIntelligence,
EditingField::AttributeWisdom,
EditingField::AttributeCharisma,
EditingField::ArmorClass,
EditingField::Initiative,
EditingField::Speed,
EditingField::HitPointsCurrent,
EditingField::HitPointsMaximum,
EditingField::ProficiencyBonus,
];
if let Some(sheet) = &character_data.sheet {
let mut saves: Vec<_> = sheet.saving_throws.keys().collect();
saves.sort();
for save in saves {
fields.push(EditingField::SavingThrow(save.clone()));
}
let mut skills: Vec<_> = sheet.skills.keys().collect();
skills.sort();
for skill in skills {
fields.push(EditingField::Skill(skill.clone()));
}
}
fields
}
pub fn handle_text_input(
keyboard: Res<ButtonInput<KeyCode>>,
mut char_events: EventReader<bevy::input::keyboard::KeyboardInput>,
mut text_input: ResMut<TextInputState>,
mut character_data: ResMut<CharacterData>,
ui_state: Res<UiState>,
) {
if ui_state.active_tab != AppTab::CharacterSheet {
return;
}
if keyboard.just_pressed(KeyCode::Tab) {
let tab_order = get_tab_order(&character_data);
if tab_order.is_empty() {
return;
}
let shift_held =
keyboard.pressed(KeyCode::ShiftLeft) || keyboard.pressed(KeyCode::ShiftRight);
if let Some(active_field) = text_input.active_field.clone() {
let current_text = text_input.current_text.clone();
apply_field_value(
&mut character_data,
&mut text_input,
&active_field,
¤t_text,
);
}
let current_idx = text_input
.active_field
.as_ref()
.and_then(|f| tab_order.iter().position(|x| x == f));
let new_idx = match current_idx {
Some(idx) => {
if shift_held {
if idx == 0 {
tab_order.len() - 1
} else {
idx - 1
}
} else {
(idx + 1) % tab_order.len()
}
}
None => {
if shift_held {
tab_order.len() - 1
} else {
0
}
}
};
let new_field = tab_order[new_idx].clone();
let new_value = get_field_value(&character_data, &new_field);
text_input.active_field = Some(new_field);
text_input.current_text = new_value.clone();
text_input.cursor_position = new_value.len();
return;
}
let Some(active_field) = text_input.active_field.clone() else {
return;
};
if keyboard.just_pressed(KeyCode::Escape) {
text_input.active_field = None;
text_input.current_text.clear();
return;
}
if keyboard.just_pressed(KeyCode::Enter) {
let current_text = text_input.current_text.clone();
apply_field_value(
&mut character_data,
&mut text_input,
&active_field,
¤t_text,
);
text_input.active_field = None;
text_input.current_text.clear();
return;
}
if keyboard.just_pressed(KeyCode::Backspace) && !text_input.current_text.is_empty() {
text_input.current_text.pop();
return;
}
let mut chars_to_add: Vec<char> = Vec::new();
for event in char_events.read() {
if event.state.is_pressed() {
if let Some(key_code) = event.key_code.to_char() {
let c = match &active_field {
EditingField::CharacterName => {
if key_code.is_alphanumeric() || key_code == ' ' {
key_code
} else {
'_'
}
}
EditingField::CharacterLevel
| EditingField::AttributeStrength
| EditingField::AttributeDexterity
| EditingField::AttributeConstitution
| EditingField::AttributeIntelligence
| EditingField::AttributeWisdom
| EditingField::AttributeCharisma
| EditingField::ArmorClass
| EditingField::Speed
| EditingField::HitPointsCurrent
| EditingField::HitPointsMaximum => {
if key_code.is_ascii_digit() || key_code == '-' {
key_code
} else {
continue;
}
}
EditingField::Initiative
| EditingField::ProficiencyBonus
| EditingField::Skill(_)
| EditingField::SavingThrow(_) => {
if key_code.is_ascii_digit() || key_code == '-' || key_code == '+' {
key_code
} else {
continue;
}
}
_ => key_code,
};
chars_to_add.push(c);
}
}
}
for c in chars_to_add {
text_input.current_text.push(c);
}
}
trait KeyCodeToChar {
fn to_char(&self) -> Option<char>;
}
impl KeyCodeToChar for KeyCode {
fn to_char(&self) -> Option<char> {
match self {
KeyCode::KeyA => Some('a'),
KeyCode::KeyB => Some('b'),
KeyCode::KeyC => Some('c'),
KeyCode::KeyD => Some('d'),
KeyCode::KeyE => Some('e'),
KeyCode::KeyF => Some('f'),
KeyCode::KeyG => Some('g'),
KeyCode::KeyH => Some('h'),
KeyCode::KeyI => Some('i'),
KeyCode::KeyJ => Some('j'),
KeyCode::KeyK => Some('k'),
KeyCode::KeyL => Some('l'),
KeyCode::KeyM => Some('m'),
KeyCode::KeyN => Some('n'),
KeyCode::KeyO => Some('o'),
KeyCode::KeyP => Some('p'),
KeyCode::KeyQ => Some('q'),
KeyCode::KeyR => Some('r'),
KeyCode::KeyS => Some('s'),
KeyCode::KeyT => Some('t'),
KeyCode::KeyU => Some('u'),
KeyCode::KeyV => Some('v'),
KeyCode::KeyW => Some('w'),
KeyCode::KeyX => Some('x'),
KeyCode::KeyY => Some('y'),
KeyCode::KeyZ => Some('z'),
KeyCode::Digit0 => Some('0'),
KeyCode::Digit1 => Some('1'),
KeyCode::Digit2 => Some('2'),
KeyCode::Digit3 => Some('3'),
KeyCode::Digit4 => Some('4'),
KeyCode::Digit5 => Some('5'),
KeyCode::Digit6 => Some('6'),
KeyCode::Digit7 => Some('7'),
KeyCode::Digit8 => Some('8'),
KeyCode::Digit9 => Some('9'),
KeyCode::Space => Some(' '),
KeyCode::Minus => Some('-'),
KeyCode::Equal => Some('+'), KeyCode::NumpadAdd => Some('+'),
KeyCode::NumpadSubtract => Some('-'),
_ => None,
}
}
}
fn apply_field_value(
character_data: &mut CharacterData,
text_input: &mut TextInputState,
field: &EditingField,
value: &str,
) {
let Some(sheet) = &mut character_data.sheet else {
return;
};
character_data.is_modified = true;
text_input.modified_fields.insert(field.clone());
match field {
EditingField::CharacterName => {
sheet.character.name = CharacterManager::sanitize_name(value);
}
EditingField::CharacterClass => {
sheet.character.class = value.to_string();
}
EditingField::CharacterRace => {
sheet.character.race = value.to_string();
}
EditingField::CharacterLevel => {
if let Ok(v) = value.parse() {
sheet.character.level = v;
}
}
EditingField::AttributeStrength => {
if let Ok(v) = value.parse() {
sheet.attributes.strength = v;
sheet.modifiers.strength = Attributes::calculate_modifier(v);
}
}
EditingField::AttributeDexterity => {
if let Ok(v) = value.parse() {
sheet.attributes.dexterity = v;
sheet.modifiers.dexterity = Attributes::calculate_modifier(v);
}
}
EditingField::AttributeConstitution => {
if let Ok(v) = value.parse() {
sheet.attributes.constitution = v;
sheet.modifiers.constitution = Attributes::calculate_modifier(v);
}
}
EditingField::AttributeIntelligence => {
if let Ok(v) = value.parse() {
sheet.attributes.intelligence = v;
sheet.modifiers.intelligence = Attributes::calculate_modifier(v);
}
}
EditingField::AttributeWisdom => {
if let Ok(v) = value.parse() {
sheet.attributes.wisdom = v;
sheet.modifiers.wisdom = Attributes::calculate_modifier(v);
}
}
EditingField::AttributeCharisma => {
if let Ok(v) = value.parse() {
sheet.attributes.charisma = v;
sheet.modifiers.charisma = Attributes::calculate_modifier(v);
}
}
EditingField::ArmorClass => {
if let Ok(v) = value.parse() {
sheet.combat.armor_class = v;
}
}
EditingField::Initiative => {
if let Some(v) = parse_modifier(value) {
sheet.combat.initiative = v;
}
}
EditingField::Speed => {
if let Ok(v) = value.parse() {
sheet.combat.speed = v;
}
}
EditingField::ProficiencyBonus => {
if let Some(v) = parse_modifier(value) {
sheet.proficiency_bonus = v;
}
}
EditingField::HitPointsCurrent => {
if let Ok(v) = value.parse() {
if let Some(hp) = &mut sheet.combat.hit_points {
hp.current = v;
}
}
}
EditingField::HitPointsMaximum => {
if let Ok(v) = value.parse() {
if let Some(hp) = &mut sheet.combat.hit_points {
hp.maximum = v;
}
}
}
EditingField::Skill(name) => {
if let Some(v) = parse_modifier(value) {
if let Some(skill) = sheet.skills.get_mut(name) {
skill.modifier = v;
}
}
}
EditingField::SavingThrow(name) => {
if let Some(v) = parse_modifier(value) {
if let Some(save) = sheet.saving_throws.get_mut(name) {
save.modifier = v;
}
}
}
EditingField::SkillLabel(old_name) => {
if let Some(skill_data) = sheet.skills.remove(old_name) {
let new_name = CharacterManager::sanitize_name(value);
if !new_name.is_empty() {
sheet.skills.insert(new_name, skill_data);
} else {
sheet.skills.insert(old_name.clone(), skill_data);
}
}
}
EditingField::SavingThrowLabel(old_name) => {
if let Some(save_data) = sheet.saving_throws.remove(old_name) {
let new_name = CharacterManager::sanitize_name(value);
if !new_name.is_empty() {
sheet.saving_throws.insert(new_name, save_data);
} else {
sheet.saving_throws.insert(old_name.clone(), save_data);
}
}
}
}
}
pub fn update_editing_display(
text_input: Res<TextInputState>,
character_data: Res<CharacterData>,
mut text_query: Query<(&mut Text, &StatFieldValue)>,
mut field_query: Query<(&StatField, &mut BackgroundColor)>,
mut label_text_query: Query<(&mut Text, &EditableLabelText), Without<StatFieldValue>>,
mut label_button_query: Query<(&EditableLabelButton, &mut BackgroundColor), Without<StatField>>,
) {
if !text_input.is_changed() {
return;
}
for (stat_field, mut bg) in field_query.iter_mut() {
if Some(&stat_field.field) == text_input.active_field.as_ref() {
*bg = BackgroundColor(Color::srgb(0.2, 0.25, 0.35));
} else if text_input.modified_fields.contains(&stat_field.field) {
*bg = BackgroundColor(FIELD_MODIFIED_BG);
} else {
*bg = BackgroundColor(FIELD_BG);
}
}
for (label_button, mut bg) in label_button_query.iter_mut() {
if Some(&label_button.field) == text_input.active_field.as_ref() {
*bg = BackgroundColor(Color::srgb(0.2, 0.25, 0.35));
} else {
*bg = BackgroundColor(Color::srgba(0.2, 0.2, 0.25, 0.5));
}
}
for (mut text, field_value) in text_query.iter_mut() {
if Some(&field_value.field) == text_input.active_field.as_ref() {
let display = format!("{}|", text_input.current_text);
if let Some(section) = text.sections.first_mut() {
section.value = display;
section.style.color = Color::srgb(0.9, 0.9, 0.5); }
} else {
let value = get_field_value(&character_data, &field_value.field);
if let Some(section) = text.sections.first_mut() {
if section.value.ends_with('|') || section.style.color != TEXT_PRIMARY {
section.value = value;
section.style.color = TEXT_PRIMARY;
}
}
}
}
for (mut text, label_text) in label_text_query.iter_mut() {
if Some(&label_text.field) == text_input.active_field.as_ref() {
let display = format!("{}|", text_input.current_text);
if let Some(section) = text.sections.first_mut() {
section.value = display;
section.style.color = Color::srgb(0.9, 0.9, 0.5); }
}
}
}
fn get_skill_ability(skill: &str) -> &'static str {
match skill.to_lowercase().as_str() {
"acrobatics" | "sleightofhand" | "stealth" => "dexterity",
"athletics" => "strength",
"arcana" | "history" | "investigation" | "nature" | "religion" => "intelligence",
"animalhandling" | "insight" | "medicine" | "perception" | "survival" => "wisdom",
"deception" | "intimidation" | "performance" | "persuasion" => "charisma",
_ => "strength",
}
}
pub fn handle_expertise_toggle(
interaction_query: Query<(&Interaction, &ExpertiseCheckbox), Changed<Interaction>>,
mut character_data: ResMut<CharacterData>,
) {
for (interaction, checkbox) in interaction_query.iter() {
if *interaction == Interaction::Pressed {
let Some(sheet) = &mut character_data.sheet else {
continue;
};
if let Some(skill) = sheet.skills.get_mut(&checkbox.skill_name) {
if skill.proficient {
let current = skill.expertise.unwrap_or(false);
skill.expertise = Some(!current);
let base_ability = get_skill_ability(&checkbox.skill_name);
let ability_mod = match base_ability {
"dexterity" => sheet.modifiers.dexterity,
"strength" => sheet.modifiers.strength,
"constitution" => sheet.modifiers.constitution,
"intelligence" => sheet.modifiers.intelligence,
"wisdom" => sheet.modifiers.wisdom,
"charisma" => sheet.modifiers.charisma,
_ => 0,
};
let prof_bonus = sheet.proficiency_bonus;
let expertise_bonus = if skill.expertise.unwrap_or(false) {
sheet.proficiency_bonus
} else {
0
};
skill.modifier = ability_mod + prof_bonus + expertise_bonus;
character_data.is_modified = true;
}
}
}
}
}
pub fn handle_group_edit_toggle(
interaction_query: Query<(&Interaction, &GroupEditButton), Changed<Interaction>>,
mut edit_state: ResMut<GroupEditState>,
) {
for (interaction, button) in interaction_query.iter() {
if *interaction == Interaction::Pressed {
if edit_state.editing_groups.contains(&button.group_type) {
edit_state.editing_groups.remove(&button.group_type);
} else {
edit_state.editing_groups.insert(button.group_type.clone());
}
}
}
}
pub fn handle_group_add_click(
interaction_query: Query<(&Interaction, &GroupAddButton), Changed<Interaction>>,
mut adding_state: ResMut<AddingEntryState>,
) {
for (interaction, button) in interaction_query.iter() {
if *interaction == Interaction::Pressed {
adding_state.adding_to = Some(button.group_type.clone());
adding_state.new_entry_name.clear();
}
}
}
pub fn handle_new_entry_confirm(
interaction_query: Query<(&Interaction, &NewEntryConfirmButton), Changed<Interaction>>,
mut adding_state: ResMut<AddingEntryState>,
mut character_data: ResMut<CharacterData>,
) {
for (interaction, button) in interaction_query.iter() {
if *interaction == Interaction::Pressed {
if adding_state.new_entry_name.is_empty() {
continue;
}
let entry_name = adding_state.new_entry_name.clone();
let group_type = button.group_type.clone();
if character_data.sheet.is_none() {
continue;
}
let mut added = false;
match group_type {
GroupType::Skills => {
if let Some(sheet) = &mut character_data.sheet {
sheet.skills.insert(
entry_name.clone(),
Skill {
modifier: 0,
proficient: false,
expertise: None,
proficiency_type: None,
},
);
added = true;
}
}
GroupType::SavingThrows => {
if let Some(sheet) = &mut character_data.sheet {
sheet.saving_throws.insert(
entry_name.clone(),
SavingThrow {
modifier: 0,
proficient: false,
},
);
added = true;
}
}
GroupType::BasicInfo => {
if let Some(sheet) = &mut character_data.sheet {
sheet
.custom_basic_info
.insert(entry_name.clone(), String::new());
added = true;
}
}
GroupType::Attributes => {
if let Some(sheet) = &mut character_data.sheet {
sheet.custom_attributes.insert(entry_name.clone(), 10);
added = true;
}
}
GroupType::Combat => {
if let Some(sheet) = &mut character_data.sheet {
sheet
.custom_combat
.insert(entry_name.clone(), String::new());
added = true;
}
}
}
if added {
character_data.is_modified = true;
}
adding_state.adding_to = None;
adding_state.new_entry_name.clear();
}
}
}
pub fn handle_new_entry_cancel(
interaction_query: Query<(&Interaction, &NewEntryCancelButton), Changed<Interaction>>,
mut adding_state: ResMut<AddingEntryState>,
) {
for (interaction, _button) in interaction_query.iter() {
if *interaction == Interaction::Pressed {
adding_state.adding_to = None;
adding_state.new_entry_name.clear();
}
}
}
pub fn handle_new_entry_input(
mut keyboard_events: EventReader<KeyboardInput>,
mut adding_state: ResMut<AddingEntryState>,
) {
if adding_state.adding_to.is_none() {
return;
}
for event in keyboard_events.read() {
if !event.state.is_pressed() {
continue;
}
match event.key_code {
KeyCode::Backspace => {
adding_state.new_entry_name.pop();
}
KeyCode::Enter => {
if adding_state.new_entry_name.is_empty() {
adding_state.adding_to = None;
}
}
KeyCode::Escape => {
adding_state.adding_to = None;
adding_state.new_entry_name.clear();
}
_ => {
if let bevy::input::keyboard::Key::Character(ref s) = event.logical_key {
for c in s.chars() {
if c.is_alphanumeric() || c == ' ' || c == '-' || c == '_' {
adding_state.new_entry_name.push(c);
}
}
}
}
}
}
}
pub fn update_new_entry_input_display(
adding_state: Res<AddingEntryState>,
input_query: Query<&Children, With<NewEntryInput>>,
mut text_query: Query<&mut Text>,
) {
if !adding_state.is_changed() {
return;
}
for children in input_query.iter() {
for &child in children.iter() {
if let Ok(mut text) = text_query.get_mut(child) {
if let Some(section) = text.sections.first_mut() {
if adding_state.new_entry_name.is_empty() {
section.value = "Enter name...".to_string();
section.style.color = TEXT_MUTED;
} else {
section.value = format!("{}|", adding_state.new_entry_name);
section.style.color = Color::srgb(0.9, 0.9, 0.5);
}
}
}
}
}
}
pub fn handle_delete_click(
interaction_query: Query<(&Interaction, &DeleteEntryButton), Changed<Interaction>>,
mut character_data: ResMut<CharacterData>,
) {
for (interaction, button) in interaction_query.iter() {
if *interaction == Interaction::Pressed {
let Some(sheet) = &mut character_data.sheet else {
continue;
};
let mut deleted = false;
match &button.group_type {
GroupType::Skills => {
sheet.skills.remove(&button.entry_id);
deleted = true;
}
GroupType::SavingThrows => {
sheet.saving_throws.remove(&button.entry_id);
deleted = true;
}
GroupType::Attributes => {
if sheet.custom_attributes.remove(&button.entry_id).is_some() {
deleted = true;
}
}
GroupType::Combat => {
if sheet.custom_combat.remove(&button.entry_id).is_some() {
deleted = true;
}
}
GroupType::BasicInfo => {
if sheet.custom_basic_info.remove(&button.entry_id).is_some() {
deleted = true;
}
}
}
if deleted {
character_data.is_modified = true;
}
}
}
}
pub fn handle_character_list_clicks(
interaction_query: Query<(&Interaction, &CharacterListItem), Changed<Interaction>>,
character_manager: Res<CharacterManager>,
mut character_data: ResMut<CharacterData>,
) {
for (interaction, list_item) in interaction_query.iter() {
if *interaction == Interaction::Pressed {
if let Some(char_file) = character_manager.available_characters.get(list_item.index) {
if char_file.is_valid {
*character_data = CharacterData::load_from_path(&char_file.path);
}
}
}
}
}
pub fn handle_new_character_click(
interaction_query: Query<&Interaction, (Changed<Interaction>, With<NewCharacterButton>)>,
mut character_data: ResMut<CharacterData>,
) {
for interaction in interaction_query.iter() {
if *interaction == Interaction::Pressed {
*character_data = CharacterData::create_new();
}
}
}
pub fn handle_save_click(
interaction_query: Query<
(&Interaction, &mut BackgroundColor),
(Changed<Interaction>, With<SaveButton>),
>,
mut character_data: ResMut<CharacterData>,
mut character_manager: ResMut<CharacterManager>,
mut text_input: ResMut<TextInputState>,
) {
for (interaction, _bg) in interaction_query.iter() {
if *interaction == Interaction::Pressed {
match character_data.save() {
Ok(_) => {
println!(
"Character saved successfully to {:?}",
character_data.file_path
);
text_input.modified_fields.clear();
let current_dir = std::env::current_dir().unwrap_or_default();
character_manager.available_characters =
CharacterManager::scan_directory(¤t_dir);
character_manager.current_character_path = character_data.file_path.clone();
}
Err(e) => {
eprintln!("Failed to save character: {}", e);
}
}
}
}
}
pub fn update_save_button_appearance(
character_data: Res<CharacterData>,
mut save_button_query: Query<&mut BackgroundColor, With<SaveButton>>,
) {
if !character_data.is_changed() {
return;
}
for mut bg in save_button_query.iter_mut() {
*bg = BackgroundColor(if character_data.is_modified {
BUTTON_BG
} else {
Color::srgb(0.3, 0.3, 0.35)
});
}
}
pub fn refresh_character_display(
character_data: Res<CharacterData>,
text_input: Res<TextInputState>,
mut stat_value_query: Query<(&mut Text, &StatFieldValue)>,
mut proficiency_query: Query<(&ProficiencyCheckbox, &mut BackgroundColor)>,
) {
if text_input.active_field.is_some() {
return;
}
let should_refresh = character_data.is_changed() || text_input.is_changed();
if !should_refresh {
return;
}
for (mut text, field_value) in stat_value_query.iter_mut() {
let value = get_field_value(&character_data, &field_value.field);
if let Some(section) = text.sections.first_mut() {
section.value = value;
section.style.color = TEXT_PRIMARY;
}
}
if let Some(sheet) = &character_data.sheet {
for (checkbox, mut bg) in proficiency_query.iter_mut() {
let has_value = match &checkbox.target {
ProficiencyTarget::Skill(name) => {
if let Some(skill) = sheet.skills.get(name) {
skill.modifier != 0
} else {
false
}
}
ProficiencyTarget::SavingThrow(name) => {
if let Some(save) = sheet.saving_throws.get(name) {
save.modifier != 0
} else {
false
}
}
};
*bg = BackgroundColor(if has_value {
PROFICIENT_COLOR
} else {
Color::NONE
});
}
}
}
pub fn update_character_list_modified_indicator(
character_data: Res<CharacterData>,
character_manager: Res<CharacterManager>,
mut text_query: Query<(&mut Text, &CharacterListItemText)>,
) {
if !character_data.is_changed() {
return;
}
for (mut text, item_text) in text_query.iter_mut() {
let is_current = character_manager
.available_characters
.get(item_text.index)
.and_then(|char_file| {
character_manager
.current_character_path
.as_ref()
.map(|p| p == &char_file.path)
})
.unwrap_or(false);
let display_name = if is_current && character_data.is_modified {
format!("{}*", item_text.base_name)
} else {
item_text.base_name.clone()
};
if let Some(section) = text.sections.first_mut() {
section.value = display_name;
}
}
}
#[allow(clippy::too_many_arguments)]
pub fn rebuild_character_panel_on_change(
mut commands: Commands,
character_data: Res<CharacterData>,
edit_state: Res<GroupEditState>,
adding_state: Res<AddingEntryState>,
icon_assets: Res<IconAssets>,
stats_panel_query: Query<Entity, With<CharacterStatsPanel>>,
screen_root_query: Query<Entity, With<CharacterScreenRoot>>,
mut last_file_path: Local<Option<std::path::PathBuf>>,
mut last_edit_state: Local<std::collections::HashSet<GroupType>>,
mut last_skills_count: Local<usize>,
mut last_saves_count: Local<usize>,
mut last_adding_state: Local<Option<GroupType>>,
mut last_is_modified: Local<bool>,
) {
let current_path = character_data.file_path.clone();
let path_changed = *last_file_path != current_path;
let edit_changed = *last_edit_state != edit_state.editing_groups;
let adding_changed = *last_adding_state != adding_state.adding_to;
let current_skills_count = character_data
.sheet
.as_ref()
.map(|s| s.skills.len())
.unwrap_or(0);
let current_saves_count = character_data
.sheet
.as_ref()
.map(|s| s.saving_throws.len())
.unwrap_or(0);
let items_changed =
*last_skills_count != current_skills_count || *last_saves_count != current_saves_count;
let modified_changed = *last_is_modified != character_data.is_modified;
if !path_changed && !edit_changed && !items_changed && !adding_changed && !modified_changed {
return;
}
*last_file_path = current_path;
*last_edit_state = edit_state.editing_groups.clone();
*last_adding_state = adding_state.adding_to.clone();
*last_skills_count = current_skills_count;
*last_saves_count = current_saves_count;
*last_is_modified = character_data.is_modified;
let Ok(screen_root) = screen_root_query.get_single() else {
return;
};
for entity in stats_panel_query.iter() {
commands.entity(entity).despawn_recursive();
}
let edit_state_ref = &*edit_state;
let adding_state_ref = &*adding_state;
let icon_assets_ref = &*icon_assets;
let new_panel = commands.spawn((
NodeBundle {
style: Style {
flex_grow: 1.0,
flex_direction: FlexDirection::Column,
overflow: Overflow::clip_y(),
..default()
},
..default()
},
CharacterStatsPanel,
)).with_children(|container| {
container
.spawn((
NodeBundle {
style: Style {
flex_direction: FlexDirection::Column,
row_gap: Val::Px(15.0),
padding: UiRect {
left: Val::Px(15.0),
right: Val::Px(15.0),
top: Val::Px(15.0),
bottom: Val::Px(50.0), },
..default()
},
..default()
},
ScrollableContent,
))
.with_children(|panel| {
if let Some(sheet) = &character_data.sheet {
spawn_header_row(panel, sheet, character_data.is_modified, icon_assets_ref);
panel
.spawn(NodeBundle {
style: Style {
flex_direction: FlexDirection::Row,
column_gap: Val::Px(20.0),
flex_wrap: FlexWrap::Wrap,
..default()
},
..default()
})
.with_children(|columns| {
columns
.spawn(NodeBundle {
style: Style {
flex_direction: FlexDirection::Column,
min_width: Val::Px(300.0),
flex_grow: 1.0,
row_gap: Val::Px(15.0),
..default()
},
..default()
})
.with_children(|col| {
spawn_basic_info_group(col, sheet, edit_state_ref, adding_state_ref, icon_assets_ref);
spawn_attributes_group(col, sheet, edit_state_ref, adding_state_ref, icon_assets_ref);
spawn_combat_group(col, sheet, edit_state_ref, adding_state_ref, icon_assets_ref);
});
columns
.spawn(NodeBundle {
style: Style {
flex_direction: FlexDirection::Column,
min_width: Val::Px(300.0),
flex_grow: 1.0,
row_gap: Val::Px(15.0),
..default()
},
..default()
})
.with_children(|col| {
spawn_saving_throws_group(col, sheet, edit_state_ref, adding_state_ref, icon_assets_ref);
spawn_skills_group(col, sheet, edit_state_ref, adding_state_ref, icon_assets_ref);
});
});
} else {
panel.spawn(TextBundle::from_section(
"No character loaded.\nSelect a character from the list or create a new one.",
TextStyle {
font_size: 18.0,
color: TEXT_SECONDARY,
..default()
},
));
}
});
}).id();
commands.entity(screen_root).add_child(new_panel);
}
pub fn setup_dnd_info_screen(mut commands: Commands, icon_assets: Res<IconAssets>) {
commands
.spawn((
NodeBundle {
style: Style {
position_type: PositionType::Absolute,
top: Val::Px(45.0),
left: Val::Px(0.0),
right: Val::Px(0.0),
bottom: Val::Px(0.0),
flex_direction: FlexDirection::Column,
padding: UiRect::all(Val::Px(20.0)),
overflow: Overflow::clip(),
..default()
},
background_color: BackgroundColor(Color::srgb(0.08, 0.08, 0.12)),
visibility: Visibility::Hidden,
..default()
},
DndInfoScreenRoot,
))
.with_children(|parent| {
parent
.spawn(NodeBundle {
style: Style {
width: Val::Percent(100.0),
height: Val::Percent(100.0),
flex_direction: FlexDirection::Column,
overflow: Overflow::clip_y(),
..default()
},
..default()
})
.with_children(|scroll| {
let icon_assets = &icon_assets;
scroll
.spawn((
NodeBundle {
style: Style {
width: Val::Percent(100.0),
max_width: Val::Px(900.0),
flex_direction: FlexDirection::Column,
row_gap: Val::Px(20.0),
padding: UiRect::all(Val::Px(20.0)),
..default()
},
..default()
},
InfoScrollContent,
))
.with_children(|content| {
spawn_info_heading_with_icon(content, icon_assets, IconType::Dice, "D&D Dice Rolling Guide", 32.0, Color::srgb(0.9, 0.8, 0.4));
spawn_info_divider(content);
spawn_info_heading_with_icon(content, icon_assets, IconType::Info, "How Ability Scores Work", 24.0, Color::srgb(0.6, 0.8, 1.0));
spawn_info_paragraph(content,
"In D&D, each character has six ability scores: Strength (STR), Dexterity (DEX), \
Constitution (CON), Intelligence (INT), Wisdom (WIS), and Charisma (CHA). \
These scores typically range from 8 to 20 for player characters.");
spawn_info_heading(content, "Ability Score → Modifier Formula", 20.0, Color::srgb(0.8, 0.8, 0.9));
spawn_info_code_block(content, "Modifier = floor((Score - 10) / 2)");
spawn_info_heading(content, "Modifier Reference Table", 18.0, Color::srgb(0.7, 0.7, 0.8));
spawn_info_table(content, &[
("Score", "Modifier"),
("1", "-5"),
("2-3", "-4"),
("4-5", "-3"),
("6-7", "-2"),
("8-9", "-1"),
("10-11", "+0"),
("12-13", "+1"),
("14-15", "+2"),
("16-17", "+3"),
("18-19", "+4"),
("20", "+5"),
]);
spawn_info_divider(content);
spawn_info_heading_with_icon(content, icon_assets, IconType::Roll, "Types of Dice Rolls", 24.0, Color::srgb(0.6, 0.8, 1.0));
spawn_info_heading(content, "1. Ability Checks", 20.0, Color::srgb(0.5, 0.9, 0.5));
spawn_info_paragraph(content,
"Used when attempting something uncertain (breaking down a door, climbing a wall).");
spawn_info_code_block(content, "Roll: 1d20 + Ability Modifier");
spawn_info_example(content, "Example: STR 16 (+3) → Roll 1d20+3");
spawn_info_heading(content, "2. Skill Checks", 20.0, Color::srgb(0.5, 0.9, 0.5));
spawn_info_paragraph(content,
"Skills are specialized applications of abilities. If proficient, add your proficiency bonus.");
spawn_info_code_block(content, "Roll: 1d20 + Ability Modifier + Proficiency Bonus (if proficient)");
spawn_info_example(content, "Example: Stealth (DEX 14, Proficient +2) → Roll 1d20+4");
spawn_info_paragraph(content, "Common skills and their abilities:");
spawn_info_bullet_list(content, &[
"STR: Athletics",
"DEX: Acrobatics, Sleight of Hand, Stealth",
"INT: Arcana, History, Investigation, Nature, Religion",
"WIS: Animal Handling, Insight, Medicine, Perception, Survival",
"CHA: Deception, Intimidation, Performance, Persuasion",
]);
spawn_info_heading(content, "3. Saving Throws", 20.0, Color::srgb(0.5, 0.9, 0.5));
spawn_info_paragraph(content,
"Used to resist spells, traps, poisons, and other threats. Each class is proficient in two saving throws.");
spawn_info_code_block(content, "Roll: 1d20 + Ability Modifier + Proficiency Bonus (if proficient)");
spawn_info_example(content, "Example: DEX save (DEX 14, Proficient +2) → Roll 1d20+4 to dodge a Fireball");
spawn_info_heading(content, "4. Attack Rolls", 20.0, Color::srgb(0.5, 0.9, 0.5));
spawn_info_paragraph(content,
"Roll to hit a target. Compare result to target's Armor Class (AC).");
spawn_info_code_block(content, "Melee: 1d20 + STR Modifier + Proficiency Bonus\nRanged/Finesse: 1d20 + DEX Modifier + Proficiency Bonus");
spawn_info_heading(content, "5. Damage Rolls", 20.0, Color::srgb(0.5, 0.9, 0.5));
spawn_info_paragraph(content,
"When you hit, roll the weapon's damage dice and add your ability modifier.");
spawn_info_code_block(content, "Damage: Weapon Dice + Ability Modifier");
spawn_info_example(content, "Example: Longsword (STR 16) → 1d8+3 damage");
spawn_info_divider(content);
spawn_info_heading_with_icon(content, icon_assets, IconType::Settings, "Using This Application", 24.0, Color::srgb(0.6, 0.8, 1.0));
spawn_info_heading(content, "Quick Roll Panel", 20.0, Color::srgb(0.9, 0.7, 0.4));
spawn_info_paragraph(content,
"On the Dice Roller tab, you'll see a Quick Rolls panel on the right showing your character's \
abilities, saving throws, and skills. Click any button to instantly roll 1d20 with that modifier applied!");
spawn_info_heading(content, "Command Line Rolling", 20.0, Color::srgb(0.9, 0.7, 0.4));
spawn_info_paragraph(content,
"Press / to enter command mode. Use these commands:");
spawn_info_bullet_list(content, &[
"--dice 2d6 or 2d6 → Roll specific dice",
"--checkon stealth → Roll 1d20 + Stealth modifier",
"--checkon dex → Roll 1d20 + DEX ability modifier",
"--checkon dex save → Roll 1d20 + DEX saving throw",
"--modifier 5 or -m 5 → Add a flat modifier",
]);
spawn_info_example(content, "Examples:\n 2d6 --checkon athletics\n --dice 1d20 --checkon perception\n d20 -m 3");
spawn_info_heading(content, "Keyboard Shortcuts", 20.0, Color::srgb(0.9, 0.7, 0.4));
spawn_info_bullet_list(content, &[
"SPACE → Roll the dice",
"R → Reset dice",
"/ → Enter command mode",
"1-9 → Re-roll from command history",
"ESC → Cancel command input",
]);
spawn_info_heading(content, "Character Management", 20.0, Color::srgb(0.9, 0.7, 0.4));
spawn_info_paragraph(content,
"Use the Character tab to create and edit characters. Click on any value to edit it. \
Your character's modifiers will automatically be used in the Dice Roller. \
Click the + button on any group to add custom entries.");
spawn_info_heading(content, "Settings", 20.0, Color::srgb(0.9, 0.7, 0.4));
spawn_info_paragraph(content,
"Click the gear icon on the Dice Roller tab to customize the background color and other settings.");
spawn_info_spacer(content, 40.0);
});
});
});
}
fn spawn_info_heading(parent: &mut ChildBuilder, text: &str, font_size: f32, color: Color) {
parent.spawn(TextBundle::from_section(
text,
TextStyle {
font_size,
color,
..default()
},
));
}
fn spawn_info_heading_with_icon(
parent: &mut ChildBuilder,
icon_assets: &IconAssets,
icon_type: IconType,
text: &str,
font_size: f32,
color: Color,
) {
parent
.spawn(NodeBundle {
style: Style {
flex_direction: FlexDirection::Row,
align_items: AlignItems::Center,
column_gap: Val::Px(10.0),
..default()
},
..default()
})
.with_children(|row| {
if let Some(icon_handle) = icon_assets.icons.get(&icon_type) {
row.spawn(ImageBundle {
style: Style {
width: Val::Px(font_size),
height: Val::Px(font_size),
..default()
},
image: UiImage::new(icon_handle.clone()),
..default()
});
}
row.spawn(TextBundle::from_section(
text,
TextStyle {
font_size,
color,
..default()
},
));
});
}
fn spawn_info_paragraph(parent: &mut ChildBuilder, text: &str) {
parent.spawn(
TextBundle::from_section(
text,
TextStyle {
font_size: 16.0,
color: Color::srgb(0.85, 0.85, 0.85),
..default()
},
)
.with_style(Style {
max_width: Val::Px(800.0),
..default()
}),
);
}
fn spawn_info_code_block(parent: &mut ChildBuilder, text: &str) {
parent
.spawn(NodeBundle {
style: Style {
padding: UiRect::all(Val::Px(12.0)),
margin: UiRect::vertical(Val::Px(8.0)),
..default()
},
background_color: BackgroundColor(Color::srgb(0.05, 0.05, 0.08)),
border_radius: BorderRadius::all(Val::Px(4.0)),
..default()
})
.with_children(|code| {
code.spawn(TextBundle::from_section(
text,
TextStyle {
font_size: 15.0,
color: Color::srgb(0.4, 0.9, 0.4),
..default()
},
));
});
}
fn spawn_info_example(parent: &mut ChildBuilder, text: &str) {
parent
.spawn(NodeBundle {
style: Style {
padding: UiRect::all(Val::Px(10.0)),
margin: UiRect::vertical(Val::Px(5.0)),
border: UiRect::left(Val::Px(3.0)),
..default()
},
background_color: BackgroundColor(Color::srgb(0.1, 0.1, 0.15)),
border_color: BorderColor(Color::srgb(0.4, 0.6, 0.8)),
..default()
})
.with_children(|ex| {
ex.spawn(TextBundle::from_section(
text,
TextStyle {
font_size: 14.0,
color: Color::srgb(0.7, 0.8, 0.9),
..default()
},
));
});
}
fn spawn_info_bullet_list(parent: &mut ChildBuilder, items: &[&str]) {
parent
.spawn(NodeBundle {
style: Style {
flex_direction: FlexDirection::Column,
row_gap: Val::Px(6.0),
padding: UiRect::left(Val::Px(20.0)),
..default()
},
..default()
})
.with_children(|list| {
for item in items {
list.spawn(TextBundle::from_section(
format!("• {}", item),
TextStyle {
font_size: 15.0,
color: Color::srgb(0.8, 0.8, 0.8),
..default()
},
));
}
});
}
fn spawn_info_table(parent: &mut ChildBuilder, rows: &[(&str, &str)]) {
parent
.spawn(NodeBundle {
style: Style {
flex_direction: FlexDirection::Column,
padding: UiRect::all(Val::Px(10.0)),
margin: UiRect::vertical(Val::Px(8.0)),
..default()
},
background_color: BackgroundColor(Color::srgb(0.08, 0.08, 0.1)),
border_radius: BorderRadius::all(Val::Px(4.0)),
..default()
})
.with_children(|table| {
for (i, (col1, col2)) in rows.iter().enumerate() {
let is_header = i == 0;
let bg_color = if is_header {
Color::srgb(0.15, 0.15, 0.2)
} else if i % 2 == 0 {
Color::srgb(0.1, 0.1, 0.12)
} else {
Color::NONE
};
let text_color = if is_header {
Color::srgb(0.9, 0.8, 0.4)
} else {
Color::srgb(0.8, 0.8, 0.8)
};
table
.spawn(NodeBundle {
style: Style {
flex_direction: FlexDirection::Row,
padding: UiRect::axes(Val::Px(10.0), Val::Px(6.0)),
..default()
},
background_color: BackgroundColor(bg_color),
..default()
})
.with_children(|row| {
row.spawn(
TextBundle::from_section(
*col1,
TextStyle {
font_size: 14.0,
color: text_color,
..default()
},
)
.with_style(Style {
width: Val::Px(100.0),
..default()
}),
);
row.spawn(TextBundle::from_section(
*col2,
TextStyle {
font_size: 14.0,
color: text_color,
..default()
},
));
});
}
});
}
fn spawn_info_divider(parent: &mut ChildBuilder) {
parent.spawn(NodeBundle {
style: Style {
width: Val::Percent(100.0),
height: Val::Px(1.0),
margin: UiRect::vertical(Val::Px(15.0)),
..default()
},
background_color: BackgroundColor(Color::srgb(0.3, 0.3, 0.4)),
..default()
});
}
fn spawn_info_spacer(parent: &mut ChildBuilder, height: f32) {
parent.spawn(NodeBundle {
style: Style {
height: Val::Px(height),
..default()
},
..default()
});
}
pub fn init_character_manager(mut commands: Commands) {
let current_dir = std::env::current_dir().unwrap_or_default();
let characters = CharacterManager::scan_directory(¤t_dir);
commands.insert_resource(CharacterManager {
available_characters: characters,
current_character_path: None,
});
commands.insert_resource(TextInputState::default());
}