feat(ui): refine drawing tabs

Use theme-driven active and hover states, integrate close controls, and keep tab spacing stable across drawing states.

Refs #481
This commit is contained in:
Hakan Seven 2026-08-04 19:04:59 +03:00
commit 7c893d8f5f
3 changed files with 109 additions and 28 deletions

View file

@ -263,6 +263,7 @@ pub(super) struct OpenCADStudio {
start: Instant, start: Instant,
tabs: Vec<DocumentTab>, tabs: Vec<DocumentTab>,
active_tab: usize, active_tab: usize,
hovered_doc_tab: Option<usize>,
tab_counter: usize, tab_counter: usize,
ribbon: Ribbon, ribbon: Ribbon,
/// Recently opened files, newest first — backs the Start page panel. /// Recently opened files, newest first — backs the Start page panel.
@ -1730,6 +1731,8 @@ pub enum Message {
TabNew, TabNew,
/// Switch to the given tab index. /// Switch to the given tab index.
TabSwitch(usize), TabSwitch(usize),
/// Drawing tab currently under the pointer; controls integrated close affordance.
DocTabHover(Option<usize>),
/// Move a drawing tab before/after another drawing tab. /// Move a drawing tab before/after another drawing tab.
TabReorder { TabReorder {
from: usize, from: usize,
@ -2834,6 +2837,7 @@ impl OpenCADStudio {
start: Instant::now(), start: Instant::now(),
tabs: vec![start_tab], tabs: vec![start_tab],
active_tab: 0, active_tab: 0,
hovered_doc_tab: None,
tab_counter: 0, tab_counter: 0,
ribbon: Ribbon::new(), ribbon: Ribbon::new(),
// Populated from the consolidated config after construction // Populated from the consolidated config after construction

View file

@ -1333,6 +1333,11 @@ impl OpenCADStudio {
Task::none() Task::none()
} }
Message::DocTabHover(index) => {
self.hovered_doc_tab = index.filter(|&idx| idx < self.tabs.len());
Task::none()
}
Message::TabReorder { from, to, after } => { Message::TabReorder { from, to, after } => {
let Some(insertion) = let Some(insertion) =
reorder_insertion_index(from, to, after, self.tabs.len()) reorder_insertion_index(from, to, after, self.tabs.len())
@ -1351,10 +1356,14 @@ impl OpenCADStudio {
if let Some(index) = self.tabs.iter().position(|tab| tab.id == active_id) { if let Some(index) = self.tabs.iter().position(|tab| tab.id == active_id) {
self.active_tab = index; self.active_tab = index;
} }
self.hovered_doc_tab = None;
Task::none() Task::none()
} }
Message::TabClose(idx) => self.on_tab_close(idx), Message::TabClose(idx) => {
self.hovered_doc_tab = None;
self.on_tab_close(idx)
}
Message::DocTabSaveAll => self.dispatch_command("SAVEALL"), Message::DocTabSaveAll => self.dispatch_command("SAVEALL"),

View file

@ -1575,7 +1575,11 @@ impl OpenCADStudio {
)); ));
} }
if self.show_file_tabs { if self.show_file_tabs {
col = col.push(doc_tab_bar(&self.tabs, self.active_tab)); col = col.push(doc_tab_bar(
&self.tabs,
self.active_tab,
self.hovered_doc_tab,
));
} }
col.push(center_stack) col.push(center_stack)
.push({ .push({
@ -2032,7 +2036,11 @@ fn doc_tab_context_menu(
.into() .into()
} }
pub(super) fn doc_tab_bar<'a>(tabs: &'a [DocumentTab], active_tab: usize) -> Element<'a, Message> { pub(super) fn doc_tab_bar<'a>(
tabs: &'a [DocumentTab],
active_tab: usize,
hovered_tab: Option<usize>,
) -> Element<'a, Message> {
// Document tabs live in a flex-wrap flow so they spill onto lower rows when // Document tabs live in a flex-wrap flow so they spill onto lower rows when
// there are more tabs than the width can hold on one line. // there are more tabs than the width can hold on one line.
let mut items: Vec<Element<'_, Message>> = Vec::new(); let mut items: Vec<Element<'_, Message>> = Vec::new();
@ -2045,6 +2053,7 @@ pub(super) fn doc_tab_bar<'a>(tabs: &'a [DocumentTab], active_tab: usize) -> Ele
for (idx, tab) in tabs.iter().enumerate() { for (idx, tab) in tabs.iter().enumerate() {
let is_active = idx == active_tab; let is_active = idx == active_tab;
let is_hovered = hovered_tab == Some(idx);
let name = crate::ui::text_util::elide(&tab.tab_display_name(), 24); let name = crate::ui::text_util::elide(&tab.tab_display_name(), 24);
let title_inner: Element<'_, Message> = if tab.dirty { let title_inner: Element<'_, Message> = if tab.dirty {
row![ row![
@ -2061,19 +2070,15 @@ pub(super) fn doc_tab_bar<'a>(tabs: &'a [DocumentTab], active_tab: usize) -> Ele
let title_btn = button(title_inner) let title_btn = button(title_inner)
.on_press(Message::TabSwitch(idx)) .on_press(Message::TabSwitch(idx))
.height(Fill) .height(Fill)
.padding([4, 12]) .padding([5, 14])
.style(move |theme: &Theme, status| { .style(move |theme: &Theme, _status| {
let palette = theme.palette(); let palette = theme.palette();
let background = match (is_active, status) {
(false, button::Status::Hovered) => {
Some(Background::Color(palette.background.weak.color))
}
_ => None,
};
button::Style { button::Style {
background, background: None,
text_color: if is_active { text_color: if is_active {
palette.primary.weak.text palette.primary.weak.text
} else if is_hovered {
palette.background.weak.text
} else { } else {
palette.background.base.text.scale_alpha(0.72) palette.background.base.text.scale_alpha(0.72)
}, },
@ -2100,8 +2105,32 @@ pub(super) fn doc_tab_bar<'a>(tabs: &'a [DocumentTab], active_tab: usize) -> Ele
let close_btn = button(text("×").size(12)) let close_btn = button(text("×").size(12))
.on_press(Message::TabClose(idx)) .on_press(Message::TabClose(idx))
.height(Fill) .height(Fill)
.padding([4, 8]) .padding([5, 9])
.style(button::subtle); .style(move |theme: &Theme, status| {
let palette = theme.palette();
button::Style {
background: matches!(
status,
button::Status::Hovered | button::Status::Pressed
)
.then_some(Background::Color(palette.warning.weak.color)),
text_color: if matches!(
status,
button::Status::Hovered | button::Status::Pressed
) {
palette.warning.weak.text
} else if is_active {
palette.primary.weak.text
} else if is_hovered {
palette.background.weak.text
} else {
palette.background.base.text.scale_alpha(0.72)
},
border: Border::default(),
shadow: iced::Shadow::default(),
snap: false,
}
});
row![title_btn, close_btn] row![title_btn, close_btn]
.spacing(0) .spacing(0)
.height(Fill) .height(Fill)
@ -2109,22 +2138,25 @@ pub(super) fn doc_tab_bar<'a>(tabs: &'a [DocumentTab], active_tab: usize) -> Ele
}; };
let tab_container = container(row_inner) let tab_container = container(row_inner)
.height(iced::Length::Fixed(23.0)) .height(iced::Length::Fixed(28.0))
.style(move |theme: &Theme| { .style(move |theme: &Theme| {
let palette = theme.palette(); let palette = theme.palette();
let background = if is_active {
palette.primary.weak.color
} else if is_hovered {
palette.background.weak.color
} else {
palette.background.base.color
};
container::Style { container::Style {
background: Some(Background::Color(if is_active { background: Some(Background::Color(background)),
palette.primary.weak.color
} else {
palette.background.base.color
})),
border: Border { border: Border {
color: if is_active { color: if is_active {
palette.primary.base.color palette.primary.base.color
} else { } else {
Color::TRANSPARENT palette.background.neutral.color
}, },
width: if is_active { 1.0 } else { 0.0 }, width: 1.0,
radius: 0.0.into(), radius: 0.0.into(),
}, },
..Default::default() ..Default::default()
@ -2157,22 +2189,58 @@ pub(super) fn doc_tab_bar<'a>(tabs: &'a [DocumentTab], active_tab: usize) -> Ele
) )
.into() .into()
}; };
items.push(tab_element); items.push(
mouse_area(tab_element)
.on_enter(Message::DocTabHover(Some(idx)))
.on_exit(Message::DocTabHover(None))
.into(),
);
} }
let new_btn = button(text("+").size(14)) let new_btn = button(text("+").size(14))
.on_press(Message::TabNew) .on_press(Message::TabNew)
.padding([4, 10]) .height(iced::Length::Fixed(28.0))
.style(button::subtle); .padding([5, 10])
.style(|theme: &Theme, status| {
let palette = theme.palette();
let hovered = matches!(
status,
button::Status::Hovered | button::Status::Pressed
);
button::Style {
background: Some(Background::Color(if hovered {
palette.background.weak.color
} else {
palette.background.base.color
})),
text_color: palette.background.base.text,
border: Border {
color: palette.background.neutral.color,
width: 1.0,
radius: 3.0.into(),
},
shadow: iced::Shadow::default(),
snap: false,
}
});
items.push(new_btn.into()); items.push(
container(new_btn)
.padding(iced::Padding {
top: 0.0,
right: 0.0,
bottom: 0.0,
left: 6.0,
})
.into(),
);
container( container(
Row::with_children(items) Row::with_children(items)
.spacing(0.0) .spacing(0.0)
.align_y(iced::Center) .align_y(iced::Center)
.wrap() .wrap()
.vertical_spacing(0.0), .vertical_spacing(2.0),
) )
.style(|theme: &Theme| container::Style { .style(|theme: &Theme| container::Style {
background: Some(Background::Color( background: Some(Background::Color(
@ -2186,7 +2254,7 @@ pub(super) fn doc_tab_bar<'a>(tabs: &'a [DocumentTab], active_tab: usize) -> Ele
..Default::default() ..Default::default()
}) })
.width(Fill) .width(Fill)
.padding([0, 2]) .padding([2, 2])
.into() .into()
} }