Skip to content

Commit a18fecd

Browse files
committed
Added Tabs&Tab tags
1 parent 5ec7553 commit a18fecd

File tree

8 files changed

+297
-3
lines changed

8 files changed

+297
-3
lines changed

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ use efx::efx; // the macro
3535
### Documentation
3636
You can see on web page https://docs.rs/efx/latest/efx/ or in files:
3737

38-
- [Introduction](efx/docs/intro.md)
38+
- [Introduction](efx/docs/intro.md) ([🇫🇷 fr](efx/docs/fr/intro.md))
3939
- [Tags](efx/docs/tags.md)
4040
- [Guide](efx/docs/guide.md)
4141

efx-syntax/snippets/efx.json

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,5 +151,25 @@
151151
"</Panel>"
152152
],
153153
"description": "EFx Panel (generic frame container)"
154+
},
155+
"Tabs": {
156+
"prefix": "Tabs",
157+
"body": [
158+
"<Tabs active=\"$1\" gap=\"$2\">",
159+
" <Tab id=\"$3\" title=\"$4\">",
160+
" $0",
161+
" </Tab>",
162+
"</Tabs>"
163+
],
164+
"description": "EFx Tabs with one Tab"
165+
},
166+
"Tab": {
167+
"prefix": "Tab",
168+
"body": [
169+
"<Tab id=\"$1\" title=\"$2\">",
170+
" $0",
171+
"</Tab>"
172+
],
173+
"description": "EFx Tab (child for <Tabs>)"
154174
}
155175
}

efx/Changelog.md

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,8 @@
11
## Changelog
22

33
#### 0.6
4-
- New Tags: Heading, Image, **Grid, Table, Tabs**
4+
- New Tags: Heading, Image, **Grid, Table,** Tabs
55
- Added Panel Tags: Window, SidePanel, TopPanel, BottomPanel, Panel
6-
- **Event attributes**
76
- `#[efx_component]` + `#[efx_slot]`
87
- **Events: `onClick`, `onHover` sugar**
98
- Sandbox

efx/docs/efx_cover.webp

-49.4 KB
Binary file not shown.

efx/docs/tags.md

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -637,3 +637,48 @@ let _ = efx!(ui, r#"
637637
* `id` helps egui keep the same widget identity across frames when the source is otherwise dynamic.
638638
* On desktop, prefer `texture` with a previously allocated `TextureId`/`TextureHandle` for performance and control. On web, `src` can be convenient alongside your asset loader.
639639

640+
---
641+
642+
## `<Tabs>` and `<Tab>`
643+
644+
Tabbed container. Controlled via a string-like `active` binding that holds the id of the currently selected tab.
645+
646+
**Syntax**
647+
```xml
648+
<Tabs active="self.active_tab" gap="8">
649+
<Tab id="home" title="Home">
650+
<Label>Welcome home!</Label>
651+
</Tab>
652+
<Tab id="logs" title="Logs">
653+
<ScrollArea axis="vertical" max-height="180">
654+
<Label monospace="true">[12:00:01] Ready.</Label>
655+
</ScrollArea>
656+
</Tab>
657+
<Tab id="about" title="About" enabled="false">
658+
<Label>This tab is disabled</Label>
659+
</Tab>
660+
</Tabs>
661+
```
662+
663+
**Attributes – `<Tabs>`**
664+
665+
| Name | Type | Default | Description |
666+
|----------|------|-------------------------------|--------------------------------------------------------------------------------|
667+
| `active` | expr | required | String/\&str expression with the id of the active tab (`"home"`, `"logs"`, …). |
668+
| `gap` | f32 | `ui.spacing().item_spacing.x` | Space between tab headers (px). |
669+
670+
**Attributes – `<Tab>`**
671+
672+
| Name | Type | Default | Description |
673+
|-----------|--------|---------|------------------------------------------------------------------|
674+
| `id` | string || Unique tab id. Used for matching and as default title. |
675+
| `title` | string | `id` | Header text. |
676+
| `enabled` | bool | `true` | When `false`, the tab header is disabled and cannot be selected. |
677+
678+
**Behavior**
679+
680+
- Clicking a tab header updates active to that tab’s `id`. You can read `active` from your state to switch content.
681+
682+
- `<Tab>` is only allowed as a child of `<Tabs>` and may contain any regular EFx content in its body.
683+
684+
- Returns `()` (container).

efx/src/render.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,11 @@ pub(crate) fn render_element_stmt<UI: ToTokens>(ui: &UI, el: &Element) -> TokenS
4949
"Row" => render_tag::<Row>(ui, el),
5050
"Column" => render_tag::<Column>(ui, el),
5151
"Resize" => render_tag::<Resize>(ui, el),
52+
"Tabs" => render_tag::<Tabs>(ui, el),
53+
"Tab" => {
54+
let msg = "efx: <Tab> is only allowed as a child of <Tabs>";
55+
quote! { compile_error!(#msg); }
56+
}
5257
"Image" => render_tag::<Image>(ui, el),
5358
"Label" => render_tag::<Label>(ui, el),
5459
"Button" => {

efx/src/tags/mod.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ pub mod row;
1212
pub mod scroll_area;
1313
pub mod separator;
1414
pub mod side_panel;
15+
pub mod tabs;
1516
pub mod text_field;
1617
pub mod top_panel;
1718
pub mod window;
@@ -30,6 +31,7 @@ pub use row::Row;
3031
pub use scroll_area::ScrollArea;
3132
pub use separator::Separator;
3233
pub use side_panel::SidePanel;
34+
pub use tabs::Tabs;
3335
pub use text_field::TextField;
3436
pub use top_panel::TopPanel;
3537
pub use window::Window;

efx/src/tags/tabs.rs

Lines changed: 223 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,223 @@
1+
use crate::interfaces::{Tag, TagAttributes};
2+
use crate::utils::attr::*;
3+
use crate::utils::expr::expr_opt;
4+
use crate::utils::render::render_children_stmt;
5+
use efx_attrnames::AttrNames;
6+
use efx_core::{Element, Node};
7+
use proc_macro2::TokenStream;
8+
use quote::{quote, ToTokens};
9+
10+
pub struct Tabs {
11+
attributes: Attributes,
12+
tabs: Vec<TabItem>,
13+
element: Element,
14+
}
15+
16+
impl Tag for Tabs {
17+
fn from_element(el: &Element) -> Result<Self, TokenStream>
18+
where
19+
Self: Sized,
20+
{
21+
let attributes = Attributes::new(el)?;
22+
23+
let mut tabs: Vec<TabItem> = Vec::new();
24+
for ch in &el.children {
25+
match ch {
26+
Node::Element(t) if t.name == "Tab" => {
27+
tabs.push(TabItem::from_element(t)?);
28+
}
29+
Node::Element(other) => {
30+
let msg = format!(
31+
"efx: <Tabs> only allows <Tab> children, got <{}>",
32+
other.name
33+
);
34+
return Err(quote! { compile_error!(#msg); });
35+
}
36+
Node::Text(txt) if txt.value.trim().is_empty() => { /* skip whitespace */ }
37+
_ => {
38+
let msg = "efx: <Tabs> does not allow text or expressions outside <Tab>";
39+
return Err(quote! { compile_error!(#msg); });
40+
}
41+
}
42+
}
43+
44+
if tabs.is_empty() {
45+
let msg = "efx: <Tabs> requires at least one <Tab>";
46+
return Err(quote! { compile_error!(#msg); });
47+
}
48+
49+
{
50+
use std::collections::BTreeSet;
51+
let mut seen = BTreeSet::new();
52+
53+
for t in &tabs {
54+
if !seen.insert(&t.id) {
55+
let msg = format!("efx: <Tabs> duplicate <Tab id=\"{}\">", t.id);
56+
return Err(quote! { compile_error!(#msg); });
57+
}
58+
}
59+
}
60+
61+
Ok(Self {
62+
attributes,
63+
tabs,
64+
element: el.clone(),
65+
})
66+
}
67+
68+
fn content<UI: ToTokens>(&self, ui: &UI) -> TokenStream {
69+
let default_active = &self.tabs[0].id;
70+
let active_init = if let Some(expr) = &self.attributes.active_expr {
71+
quote!({
72+
// приводим к String (требуем совместимый тип у пользователя)
73+
let __v = (#expr);
74+
::std::string::ToString::to_string(&__v)
75+
})
76+
} else {
77+
quote!(::std::string::ToString::to_string(#default_active))
78+
};
79+
80+
let mut header_ts = TokenStream::new();
81+
for t in &self.tabs {
82+
let id = &t.id;
83+
let title = t.title_ts();
84+
let clickable = if t.enabled.unwrap_or(true) {
85+
quote! {
86+
let __resp = ui.selectable_label(__efx_active == #id, #title);
87+
if __resp.clicked() {
88+
__efx_active = ::std::string::ToString::to_string(#id);
89+
}
90+
}
91+
} else {
92+
quote! {
93+
let __resp = ui.add_enabled(false, egui::SelectableLabel::new(__efx_active == #id, #title));
94+
let _ = __resp;
95+
}
96+
};
97+
98+
header_ts.extend(clickable);
99+
}
100+
101+
let mut content_ts = TokenStream::new();
102+
for t in &self.tabs {
103+
let id = &t.id;
104+
let body = render_children_stmt(&quote!(ui), &t.children);
105+
content_ts.extend(quote! {
106+
#id => { #body }
107+
});
108+
}
109+
110+
let (prolog, epilog) = if let Some(g) = self.attributes.gap {
111+
(
112+
quote! {
113+
let __efx_old_gap_x = #ui.spacing().item_spacing.x;
114+
#ui.spacing_mut().item_spacing.x = #g as f32;
115+
},
116+
quote! {
117+
#ui.spacing_mut().item_spacing.x = __efx_old_gap_x;
118+
},
119+
)
120+
} else {
121+
(quote!(), quote!())
122+
};
123+
124+
let write_back = if let Some(expr) = &self.attributes.active_expr {
125+
quote!( #expr = __efx_active.clone(); )
126+
} else {
127+
quote!()
128+
};
129+
130+
quote! {{
131+
// initialize the active tab
132+
let mut __efx_active: ::std::string::String = #active_init;
133+
134+
// header
135+
#prolog
136+
#ui.horizontal(|ui| {
137+
#header_ts
138+
});
139+
#epilog
140+
141+
#ui.add(egui::widgets::Separator::default());
142+
143+
// content
144+
match __efx_active.as_str() {
145+
#content_ts
146+
_ => { /* unknown id - don't draw anything */ }
147+
}
148+
149+
// sync back (if controlled mode)
150+
#write_back
151+
()
152+
}}
153+
}
154+
155+
fn render<UI: ToTokens>(&self, ui: &UI) -> TokenStream {
156+
self.content(ui)
157+
}
158+
}
159+
160+
#[derive(Clone, Debug)]
161+
struct TabItem {
162+
id: String,
163+
title: Option<String>,
164+
enabled: Option<bool>,
165+
children: Vec<Node>,
166+
}
167+
168+
impl TabItem {
169+
fn from_element(el: &Element) -> Result<Self, TokenStream> {
170+
const KNOWN: &[&str] = &["id", "title", "enabled"];
171+
let map = match attr_map(el, KNOWN, "Tab") {
172+
Ok(m) => m,
173+
Err(err) => return Err(err),
174+
};
175+
176+
let id = match map.get("id") {
177+
Some(s) if !s.is_empty() => (*s).to_string(),
178+
_ => {
179+
let msg = "efx: <Tab> requires non-empty `id`";
180+
return Err(quote! { compile_error!(#msg); });
181+
}
182+
};
183+
184+
Ok(Self {
185+
id,
186+
title: map.get("title").map(|s| (*s).to_string()),
187+
enabled: bool_opt(&map, "enabled")?,
188+
children: el.children.clone(),
189+
})
190+
}
191+
192+
fn title_ts(&self) -> TokenStream {
193+
if let Some(t) = &self.title {
194+
quote!(#t)
195+
} else {
196+
// By default, use id as the title
197+
let id = &self.id;
198+
quote!(#id)
199+
}
200+
}
201+
}
202+
203+
#[derive(Clone, Debug, AttrNames)]
204+
struct Attributes {
205+
/// Controlled mode: String/&str type expression with the id of the active tab.
206+
active_expr: Option<syn::Expr>,
207+
/// Indentation between headings, px
208+
gap: Option<f32>,
209+
}
210+
211+
impl TagAttributes for Attributes {
212+
fn new(el: &Element) -> Result<Self, TokenStream> {
213+
let map = match attr_map(el, Attributes::ATTR_NAMES, "Tabs") {
214+
Ok(m) => m,
215+
Err(err) => return Err(err),
216+
};
217+
218+
Ok(Self {
219+
active_expr: expr_opt(&map, "active")?,
220+
gap: f32_opt(&map, "gap")?,
221+
})
222+
}
223+
}

0 commit comments

Comments
 (0)