Skip to content

Commit c4a2068

Browse files
authored
Implement Component Groups (#907)
Component groups allow nesting components in the opposite layout direction to their parent, enabling horizontal rows inside vertical layouts and vice versa. Groups can be nested arbitrarily deep, with the direction alternating at each level. The layout editor presents all components, including those nested inside groups, in a single flat list with indentation levels. Empty groups show placeholder entries. Adding, removing, moving, and duplicating components all work across group boundaries. The editor state exposes the layout direction at the selected position.
1 parent d641140 commit c4a2068

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

69 files changed

+2317
-194
lines changed

capi/bind_gen/src/typescript.ts

Lines changed: 27 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -599,16 +599,16 @@ export interface TextComponentStateJson {
599599
display_two_rows: boolean,
600600
/**
601601
* The color of the left part of the split up text or the whole text if
602-
* it's not split up. If `None` is specified, the color is taken from the
602+
* it's not split up. If null is specified, the color is taken from the
603603
* layout.
604604
*/
605-
left_center_color: Color,
605+
left_center_color: Color | null,
606606
/**
607607
* The color of the right part of the split up text. This can be ignored if
608-
* the text is not split up. If `None` is specified, the color is taken
608+
* the text is not split up. If `null` is specified, the color is taken
609609
* from the layout.
610610
*/
611-
right_color: Color,
611+
right_color: Color | null,
612612
/** The text to show for the component. */
613613
text: TextComponentStateText,
614614
}
@@ -671,12 +671,27 @@ export interface DetailedTimerComponentComparisonStateJson {
671671
* properly.
672672
*/
673673
export interface LayoutEditorStateJson {
674-
/** The name of all the components in the layout. */
674+
/** The name of all the components in the layout, including those nested
675+
* inside groups. */
675676
components: string[],
677+
/** The indentation level of each component (0 = top level, 1 = inside a
678+
* group, etc.). */
679+
indent_levels: number[],
680+
/** Whether each component is an empty group placeholder. */
681+
is_placeholder: boolean[],
676682
/** Describes which actions are currently available. */
677683
buttons: LayoutEditorButtonsJson,
678-
/** The index of the currently selected component. */
684+
/** The flat index of the currently selected component. */
679685
selected_component: number,
686+
/**
687+
* The layout direction at the selected component's position. This is the
688+
* direction of the container that the selected component belongs to. A
689+
* component added at this position would be laid out in this direction. For
690+
* example, in a vertical root layout this is "Vertical" at the top level.
691+
* Adding a group here creates a row (horizontal), adding a row's
692+
* placeholder creates a column (vertical), and so on.
693+
*/
694+
layout_direction: LayoutDirection,
680695
/**
681696
* A generic description of the settings available for the selected
682697
* component and their current values.
@@ -710,6 +725,11 @@ export interface LayoutEditorButtonsJson {
710725
* the last component is selected, it can't be moved.
711726
*/
712727
can_move_down: boolean,
728+
/**
729+
* Describes whether the currently selected component can be duplicated.
730+
* Placeholders can't be duplicated.
731+
*/
732+
can_duplicate: boolean,
713733
}
714734

715735
/** A generic description of the settings available and their current values. */
@@ -740,6 +760,7 @@ export interface SettingsDescriptionFieldJson {
740760
export type SettingsDescriptionValueJson =
741761
{ Bool: boolean } |
742762
{ UInt: number } |
763+
{ OptionalUInt: number | null } |
743764
{ Int: number } |
744765
{ String: string } |
745766
{ OptionalString: string | null } |

capi/src/group_component.rs

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
//! A Component Group groups multiple components together and lays them out
2+
//! in the opposite direction to the parent, enabling nested layout hierarchies.
3+
4+
use crate::component::OwnedComponent;
5+
use livesplit_core::component::group::Component as GroupComponent;
6+
7+
/// type
8+
pub type OwnedGroupComponent = Box<GroupComponent>;
9+
10+
/// Creates a new empty Group Component.
11+
#[unsafe(no_mangle)]
12+
pub extern "C" fn GroupComponent_new() -> OwnedGroupComponent {
13+
Box::new(GroupComponent::new())
14+
}
15+
16+
/// drop
17+
#[unsafe(no_mangle)]
18+
pub extern "C" fn GroupComponent_drop(this: OwnedGroupComponent) {
19+
drop(this);
20+
}
21+
22+
/// Converts the Group Component into a generic component suitable for using
23+
/// with a layout.
24+
#[unsafe(no_mangle)]
25+
pub extern "C" fn GroupComponent_into_generic(this: OwnedGroupComponent) -> OwnedComponent {
26+
Box::new((*this).into())
27+
}
28+
29+
/// Adds a component to the end of the group.
30+
#[unsafe(no_mangle)]
31+
pub extern "C" fn GroupComponent_add_component(
32+
this: &mut GroupComponent,
33+
component: OwnedComponent,
34+
) {
35+
this.components.push(*component);
36+
}
37+
38+
/// Returns the number of components in the group.
39+
#[unsafe(no_mangle)]
40+
pub extern "C" fn GroupComponent_len(this: &GroupComponent) -> usize {
41+
this.components.len()
42+
}
43+
44+
/// Returns the size override of the group. In horizontal mode this is the
45+
/// height, in vertical mode it is the width. 0xFFFFFFFF means automatic sizing.
46+
#[unsafe(no_mangle)]
47+
pub extern "C" fn GroupComponent_size(this: &GroupComponent) -> u32 {
48+
this.size.unwrap_or(u32::MAX)
49+
}
50+
51+
/// Sets the size override of the group. In horizontal mode this sets the
52+
/// height, in vertical mode it sets the width. 0xFFFFFFFF means automatic
53+
/// sizing.
54+
#[unsafe(no_mangle)]
55+
pub extern "C" fn GroupComponent_set_size(this: &mut GroupComponent, size: u32) {
56+
this.size = if size != u32::MAX { Some(size) } else { None };
57+
}

capi/src/group_component_state.rs

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
//! The state object describes the information to visualize for this component.
2+
3+
use livesplit_core::{component::group::State as GroupComponentState, layout::ComponentState};
4+
use std::os::raw::c_char;
5+
6+
/// Returns the number of components in a Group State.
7+
#[unsafe(no_mangle)]
8+
pub extern "C" fn GroupComponentState_len(this: &GroupComponentState) -> usize {
9+
this.components.len()
10+
}
11+
12+
/// Returns a string describing the type of the component at the specified
13+
/// index within a Group State.
14+
#[unsafe(no_mangle)]
15+
pub extern "C" fn GroupComponentState_component_type(
16+
this: &GroupComponentState,
17+
index: usize,
18+
) -> *const c_char {
19+
(match this.components[index] {
20+
ComponentState::BlankSpace(_) => "BlankSpace\0",
21+
ComponentState::DetailedTimer(_) => "DetailedTimer\0",
22+
ComponentState::Graph(_) => "Graph\0",
23+
ComponentState::KeyValue(_) => "KeyValue\0",
24+
ComponentState::Separator(_) => "Separator\0",
25+
ComponentState::Splits(_) => "Splits\0",
26+
ComponentState::Text(_) => "Text\0",
27+
ComponentState::Timer(_) => "Timer\0",
28+
ComponentState::Title(_) => "Title\0",
29+
ComponentState::Group(_) => "Group\0",
30+
})
31+
.as_ptr()
32+
.cast()
33+
}
34+
35+
/// Returns the size override of a Group State. In horizontal mode this is the
36+
/// height, in vertical mode it is the width. 0xFFFFFFFF means automatic
37+
/// sizing.
38+
#[unsafe(no_mangle)]
39+
pub extern "C" fn GroupComponentState_size(this: &GroupComponentState) -> u32 {
40+
this.size.unwrap_or(u32::MAX)
41+
}

capi/src/layout_editor_state.rs

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,12 +32,14 @@ pub extern "C" fn LayoutEditorState_component_text(
3232
///
3333
/// The bits are as follows:
3434
///
35+
/// * `0x08` - Can duplicate the current component
3536
/// * `0x04` - Can remove the current component
3637
/// * `0x02` - Can move the current component up
3738
/// * `0x01` - Can move the current component down
3839
#[unsafe(no_mangle)]
3940
pub extern "C" fn LayoutEditorState_buttons(this: &LayoutEditorState) -> u8 {
40-
((this.buttons.can_remove as u8) << 2)
41+
((this.buttons.can_duplicate as u8) << 3)
42+
| ((this.buttons.can_remove as u8) << 2)
4143
| ((this.buttons.can_move_up as u8) << 1)
4244
| this.buttons.can_move_down as u8
4345
}
@@ -94,3 +96,31 @@ pub extern "C" fn LayoutEditorState_field_value(
9496
&this.general_settings.fields[index].value
9597
}
9698
}
99+
100+
/// Returns the indentation level of the component at the specified index.
101+
/// 0 means top level, 1 means inside a group, etc.
102+
#[unsafe(no_mangle)]
103+
pub extern "C" fn LayoutEditorState_component_indent_level(
104+
this: &LayoutEditorState,
105+
index: usize,
106+
) -> u32 {
107+
this.indent_levels[index]
108+
}
109+
110+
/// Returns whether the component at the specified index is a placeholder for
111+
/// an empty group rather than an actual component.
112+
#[unsafe(no_mangle)]
113+
pub extern "C" fn LayoutEditorState_component_is_placeholder(
114+
this: &LayoutEditorState,
115+
index: usize,
116+
) -> bool {
117+
this.is_placeholder[index]
118+
}
119+
120+
/// Returns the layout direction of the selected component's container. This
121+
/// indicates whether a new group added at this position would become a row or
122+
/// column.
123+
#[unsafe(no_mangle)]
124+
pub extern "C" fn LayoutEditorState_layout_direction(this: &LayoutEditorState) -> u8 {
125+
this.layout_direction as u8
126+
}

capi/src/layout_state.rs

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,10 @@ use livesplit_core::{
1111
component::{
1212
blank_space::State as BlankSpaceComponentState,
1313
detailed_timer::State as DetailedTimerComponentState, graph::State as GraphComponentState,
14-
key_value::State as KeyValueComponentState, separator::State as SeparatorComponentState,
15-
splits::State as SplitsComponentState, text::State as TextComponentState,
16-
timer::State as TimerComponentState, title::State as TitleComponentState,
14+
group::State as GroupComponentState, key_value::State as KeyValueComponentState,
15+
separator::State as SeparatorComponentState, splits::State as SplitsComponentState,
16+
text::State as TextComponentState, timer::State as TimerComponentState,
17+
title::State as TitleComponentState,
1718
},
1819
layout::{ComponentState, LayoutState},
1920
};
@@ -64,6 +65,7 @@ pub extern "C" fn LayoutState_component_type(this: &LayoutState, index: usize) -
6465
ComponentState::Text(_) => "Text\0",
6566
ComponentState::Timer(_) => "Timer\0",
6667
ComponentState::Title(_) => "Title\0",
68+
ComponentState::Group(_) => "Group\0",
6769
})
6870
.as_ptr()
6971
.cast()
@@ -176,3 +178,15 @@ pub extern "C" fn LayoutState_component_as_title(
176178
_ => panic!("wrong component state type"),
177179
}
178180
}
181+
182+
/// Gets the Group component state at the specified index.
183+
#[unsafe(no_mangle)]
184+
pub extern "C" fn LayoutState_component_as_group(
185+
this: &LayoutState,
186+
index: usize,
187+
) -> &GroupComponentState {
188+
match &this.components[index] {
189+
ComponentState::Group(x) => x,
190+
_ => panic!("wrong component state type"),
191+
}
192+
}

capi/src/lib.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,8 @@ pub mod fuzzy_list;
3636
pub mod general_layout_settings;
3737
pub mod graph_component;
3838
pub mod graph_component_state;
39+
pub mod group_component;
40+
pub mod group_component_state;
3941
pub mod hotkey_config;
4042
pub mod hotkey_system;
4143
pub mod image_cache;

capi/src/setting_value.rs

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,24 @@ pub extern "C" fn SettingValue_from_uint(value: u32) -> OwnedSettingValue {
4848
Box::new((value as u64).into())
4949
}
5050

51+
/// Creates a new setting value from an optional unsigned integer. A value of
52+
/// 0xFFFFFFFF means that the value is empty and has no unsigned integer.
53+
#[unsafe(no_mangle)]
54+
pub extern "C" fn SettingValue_from_optional_uint(value: u32) -> OwnedSettingValue {
55+
let v = if value == u32::MAX {
56+
None
57+
} else {
58+
Some(value as u64)
59+
};
60+
Box::new(v.into())
61+
}
62+
63+
/// Creates a new empty setting value that has the type `optional uint`.
64+
#[unsafe(no_mangle)]
65+
pub extern "C" fn SettingValue_from_optional_empty_uint() -> OwnedSettingValue {
66+
Box::new(None::<u64>.into())
67+
}
68+
5169
/// Creates a new setting value from a signed integer.
5270
#[unsafe(no_mangle)]
5371
pub extern "C" fn SettingValue_from_int(value: i32) -> OwnedSettingValue {

crates/livesplit-auto-splitting/src/runtime/api/wasi/windows.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,7 @@ fn resolve_network_drive_path(drive: u8, remote_buffer: &mut Vec<u16>) -> Option
9292
// least up until the nul-terminator.
9393
unsafe {
9494
// There should always be a nul-terminator, but if there isn't,
95-
// it's better if we return `None` than read out of bounds /
95+
// it's better if we return [`None`] than read out of bounds /
9696
// uninitialized bytes.
9797
let len = remote_buffer
9898
.spare_capacity_mut()

crates/livesplit-auto-splitting/src/timer.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ pub trait Timer: Send + 'static {
3636
/// Returns the current state of the timer.
3737
fn state(&self) -> TimerState;
3838
/// Accesses the index of the split the attempt is currently on.
39-
/// If there's no attempt in progress, `None` is returned instead.
39+
/// If there's no attempt in progress, [`None`] is returned instead.
4040
/// This returns an index that is equal to the amount of segments
4141
/// when the attempt is finished, but has not been reset.
4242
/// So you need to be careful when using this value for indexing.
@@ -46,7 +46,7 @@ pub trait Timer: Send + 'static {
4646
/// Returns `Some(true)` if the segment was splitted,
4747
/// or `Some(false)` if skipped.
4848
/// If `idx` is greater than or equal to the current split index,
49-
/// `None` is returned instead.
49+
/// [`None`] is returned instead.
5050
fn segment_splitted(&self, idx: usize) -> Option<bool>;
5151
/// Starts the timer.
5252
fn start(&mut self);

src/comparison/mod.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -135,7 +135,7 @@ pub fn or_current<'a>(comparison: Option<&'a str>, timer: &'a Timer) -> &'a str
135135
}
136136

137137
/// Tries to resolve the given comparison based on a Timer object. If either
138-
/// `None` is given or the comparison doesn't exist, `None` is returned.
138+
/// [`None`] is given or the comparison doesn't exist, [`None`] is returned.
139139
/// Otherwise the comparison name stored in the Timer is returned by reference.
140140
pub fn resolve<'a>(comparison: &Option<String>, timer: &'a Timer) -> Option<&'a str> {
141141
let comparison = comparison.as_ref()?;

0 commit comments

Comments
 (0)