move gpui-components to ui crate
This commit is contained in:
BIN
crates/ui/.DS_Store
vendored
Normal file
BIN
crates/ui/.DS_Store
vendored
Normal file
Binary file not shown.
@@ -6,4 +6,21 @@ publish = false
|
||||
|
||||
[dependencies]
|
||||
gpui.workspace = true
|
||||
components.workspace = true
|
||||
|
||||
rust-embed.workspace = true
|
||||
smol.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
smallvec.workspace = true
|
||||
anyhow.workspace = true
|
||||
itertools.workspace = true
|
||||
chrono.workspace = true
|
||||
|
||||
paste = "1"
|
||||
regex = "1"
|
||||
unicode-segmentation = "1.11.0"
|
||||
uuid = "1.10"
|
||||
once_cell = "1.19.0"
|
||||
image = "0.25.1"
|
||||
usvg = { version = "0.44.0", default-features = false, features = ["system-fonts", "text"] }
|
||||
resvg = { version = "0.44.0", default-features = false, features = ["system-fonts", "text"] }
|
||||
|
||||
201
crates/ui/LICENSE
Normal file
201
crates/ui/LICENSE
Normal file
@@ -0,0 +1,201 @@
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright [yyyy] [name of copyright owner]
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
0
crates/ui/README.md
Normal file
0
crates/ui/README.md
Normal file
2000
crates/ui/colors.json
Normal file
2000
crates/ui/colors.json
Normal file
File diff suppressed because it is too large
Load Diff
BIN
crates/ui/src/.DS_Store
vendored
Normal file
BIN
crates/ui/src/.DS_Store
vendored
Normal file
Binary file not shown.
300
crates/ui/src/accordion.rs
Normal file
300
crates/ui/src/accordion.rs
Normal file
@@ -0,0 +1,300 @@
|
||||
use std::{cell::Cell, rc::Rc, sync::Arc};
|
||||
|
||||
use gpui::{
|
||||
div, prelude::FluentBuilder as _, rems, AnyElement, Div, ElementId, InteractiveElement as _,
|
||||
IntoElement, ParentElement, RenderOnce, SharedString, StatefulInteractiveElement as _, Styled,
|
||||
WindowContext,
|
||||
};
|
||||
|
||||
use crate::{h_flex, theme::ActiveTheme as _, v_flex, Icon, IconName, Sizable, Size};
|
||||
|
||||
/// An AccordionGroup is a container for multiple Accordion elements.
|
||||
#[derive(IntoElement)]
|
||||
pub struct Accordion {
|
||||
id: ElementId,
|
||||
base: Div,
|
||||
multiple: bool,
|
||||
size: Size,
|
||||
bordered: bool,
|
||||
disabled: bool,
|
||||
children: Vec<AccordionItem>,
|
||||
on_toggle_click: Option<Arc<dyn Fn(&[usize], &mut WindowContext) + Send + Sync>>,
|
||||
}
|
||||
|
||||
impl Accordion {
|
||||
pub fn new(id: impl Into<ElementId>) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
base: v_flex().gap_1(),
|
||||
multiple: false,
|
||||
size: Size::default(),
|
||||
bordered: true,
|
||||
children: Vec::new(),
|
||||
disabled: false,
|
||||
on_toggle_click: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn multiple(mut self, multiple: bool) -> Self {
|
||||
self.multiple = multiple;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn bordered(mut self, bordered: bool) -> Self {
|
||||
self.bordered = bordered;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn disabled(mut self, disabled: bool) -> Self {
|
||||
self.disabled = disabled;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn item<F>(mut self, child: F) -> Self
|
||||
where
|
||||
F: FnOnce(AccordionItem) -> AccordionItem,
|
||||
{
|
||||
let item = child(AccordionItem::new());
|
||||
self.children.push(item);
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets the on_toggle_click callback for the AccordionGroup.
|
||||
///
|
||||
/// The first argument `Vec<usize>` is the indices of the open accordions.
|
||||
pub fn on_toggle_click(
|
||||
mut self,
|
||||
on_toggle_click: impl Fn(&[usize], &mut WindowContext) + Send + Sync + 'static,
|
||||
) -> Self {
|
||||
self.on_toggle_click = Some(Arc::new(on_toggle_click));
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl Sizable for Accordion {
|
||||
fn with_size(mut self, size: impl Into<Size>) -> Self {
|
||||
self.size = size.into();
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for Accordion {
|
||||
fn render(self, _: &mut WindowContext) -> impl IntoElement {
|
||||
let mut open_ixs: Vec<usize> = Vec::new();
|
||||
let multiple = self.multiple;
|
||||
let state = Rc::new(Cell::new(None));
|
||||
|
||||
self.children
|
||||
.iter()
|
||||
.enumerate()
|
||||
.for_each(|(ix, accordion)| {
|
||||
if accordion.open {
|
||||
open_ixs.push(ix);
|
||||
}
|
||||
});
|
||||
|
||||
self.base
|
||||
.id(self.id)
|
||||
.children(
|
||||
self.children
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(ix, accordion)| {
|
||||
let state = Rc::clone(&state);
|
||||
accordion
|
||||
.with_size(self.size)
|
||||
.bordered(self.bordered)
|
||||
.when(self.disabled, |this| this.disabled(true))
|
||||
.on_toggle_click(move |_, _| {
|
||||
state.set(Some(ix));
|
||||
})
|
||||
}),
|
||||
)
|
||||
.when_some(
|
||||
self.on_toggle_click.filter(|_| !self.disabled),
|
||||
move |this, on_toggle_click| {
|
||||
this.on_click(move |_, cx| {
|
||||
let mut open_ixs = open_ixs.clone();
|
||||
if let Some(ix) = state.get() {
|
||||
if multiple {
|
||||
if let Some(pos) = open_ixs.iter().position(|&i| i == ix) {
|
||||
open_ixs.remove(pos);
|
||||
} else {
|
||||
open_ixs.push(ix);
|
||||
}
|
||||
} else {
|
||||
let was_open = open_ixs.iter().any(|&i| i == ix);
|
||||
open_ixs.clear();
|
||||
if !was_open {
|
||||
open_ixs.push(ix);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
on_toggle_click(&open_ixs, cx);
|
||||
})
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// An Accordion is a vertically stacked list of items, each of which can be expanded to reveal the content associated with it.
|
||||
#[derive(IntoElement)]
|
||||
pub struct AccordionItem {
|
||||
icon: Option<Icon>,
|
||||
title: AnyElement,
|
||||
content: AnyElement,
|
||||
open: bool,
|
||||
size: Size,
|
||||
bordered: bool,
|
||||
disabled: bool,
|
||||
on_toggle_click: Option<Arc<dyn Fn(&bool, &mut WindowContext)>>,
|
||||
}
|
||||
|
||||
impl AccordionItem {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
icon: None,
|
||||
title: SharedString::default().into_any_element(),
|
||||
content: SharedString::default().into_any_element(),
|
||||
open: false,
|
||||
disabled: false,
|
||||
on_toggle_click: None,
|
||||
size: Size::default(),
|
||||
bordered: true,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn icon(mut self, icon: impl Into<Icon>) -> Self {
|
||||
self.icon = Some(icon.into());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn title(mut self, title: impl IntoElement) -> Self {
|
||||
self.title = title.into_any_element();
|
||||
self
|
||||
}
|
||||
|
||||
pub fn content(mut self, content: impl IntoElement) -> Self {
|
||||
self.content = content.into_any_element();
|
||||
self
|
||||
}
|
||||
|
||||
pub fn bordered(mut self, bordered: bool) -> Self {
|
||||
self.bordered = bordered;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn open(mut self, open: bool) -> Self {
|
||||
self.open = open;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn disabled(mut self, disabled: bool) -> Self {
|
||||
self.disabled = disabled;
|
||||
self
|
||||
}
|
||||
|
||||
fn on_toggle_click(
|
||||
mut self,
|
||||
on_toggle_click: impl Fn(&bool, &mut WindowContext) + 'static,
|
||||
) -> Self {
|
||||
self.on_toggle_click = Some(Arc::new(on_toggle_click));
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl Sizable for AccordionItem {
|
||||
fn with_size(mut self, size: impl Into<Size>) -> Self {
|
||||
self.size = size.into();
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for AccordionItem {
|
||||
fn render(self, cx: &mut WindowContext) -> impl IntoElement {
|
||||
let text_size = match self.size {
|
||||
Size::XSmall => rems(0.875),
|
||||
Size::Small => rems(0.875),
|
||||
_ => rems(1.0),
|
||||
};
|
||||
|
||||
v_flex()
|
||||
.bg(cx.theme().accordion)
|
||||
.overflow_hidden()
|
||||
.when(self.bordered, |this| {
|
||||
this.border_1().border_color(cx.theme().border).rounded_md()
|
||||
})
|
||||
.text_size(text_size)
|
||||
.child(
|
||||
h_flex()
|
||||
.id("accordion-title")
|
||||
.justify_between()
|
||||
.map(|this| match self.size {
|
||||
Size::XSmall => this.py_0().px_1p5(),
|
||||
Size::Small => this.py_0p5().px_2(),
|
||||
Size::Large => this.py_1p5().px_4(),
|
||||
_ => this.py_1().px_3(),
|
||||
})
|
||||
.when(self.open, |this| {
|
||||
this.when(self.bordered, |this| {
|
||||
this.bg(cx.theme().accordion_active)
|
||||
.text_color(cx.theme().foreground)
|
||||
.border_b_1()
|
||||
.border_color(cx.theme().border)
|
||||
})
|
||||
})
|
||||
.child(
|
||||
h_flex()
|
||||
.items_center()
|
||||
.map(|this| match self.size {
|
||||
Size::XSmall => this.gap_1(),
|
||||
Size::Small => this.gap_1(),
|
||||
_ => this.gap_2(),
|
||||
})
|
||||
.when_some(self.icon, |this, icon| {
|
||||
this.child(
|
||||
icon.with_size(self.size)
|
||||
.text_color(cx.theme().muted_foreground),
|
||||
)
|
||||
})
|
||||
.child(self.title),
|
||||
)
|
||||
.when(!self.disabled, |this| {
|
||||
this.cursor_pointer()
|
||||
.hover(|this| this.bg(cx.theme().accordion_hover))
|
||||
.child(
|
||||
Icon::new(if self.open {
|
||||
IconName::ChevronUp
|
||||
} else {
|
||||
IconName::ChevronDown
|
||||
})
|
||||
.xsmall()
|
||||
.text_color(cx.theme().muted_foreground),
|
||||
)
|
||||
})
|
||||
.when_some(
|
||||
self.on_toggle_click.filter(|_| !self.disabled),
|
||||
|this, on_toggle_click| {
|
||||
this.on_click({
|
||||
move |_, cx| {
|
||||
on_toggle_click(&!self.open, cx);
|
||||
}
|
||||
})
|
||||
},
|
||||
),
|
||||
)
|
||||
.when(self.open, |this| {
|
||||
this.child(
|
||||
div()
|
||||
.map(|this| match self.size {
|
||||
Size::XSmall => this.p_1p5(),
|
||||
Size::Small => this.p_2(),
|
||||
Size::Large => this.p_4(),
|
||||
_ => this.p_3(),
|
||||
})
|
||||
.child(self.content),
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
19
crates/ui/src/animation.rs
Normal file
19
crates/ui/src/animation.rs
Normal file
@@ -0,0 +1,19 @@
|
||||
/// A cubic bezier function like CSS `cubic-bezier`.
|
||||
///
|
||||
/// Builder:
|
||||
///
|
||||
/// https://cubic-bezier.com
|
||||
pub fn cubic_bezier(x1: f32, y1: f32, x2: f32, y2: f32) -> impl Fn(f32) -> f32 {
|
||||
move |t: f32| {
|
||||
let one_t = 1.0 - t;
|
||||
let one_t2 = one_t * one_t;
|
||||
let t2 = t * t;
|
||||
let t3 = t2 * t;
|
||||
|
||||
// The Bezier curve function for x and y, where x0 = 0, y0 = 0, x3 = 1, y3 = 1
|
||||
let _x = 3.0 * x1 * one_t2 * t + 3.0 * x2 * one_t * t2 + t3;
|
||||
let y = 3.0 * y1 * one_t2 * t + 3.0 * y2 * one_t * t2 + t3;
|
||||
|
||||
y
|
||||
}
|
||||
}
|
||||
123
crates/ui/src/badge.rs
Normal file
123
crates/ui/src/badge.rs
Normal file
@@ -0,0 +1,123 @@
|
||||
use crate::{theme::ActiveTheme as _, Sizable, Size};
|
||||
use gpui::{
|
||||
div, prelude::FluentBuilder as _, relative, Div, Hsla, InteractiveElement as _, IntoElement,
|
||||
ParentElement, RenderOnce, Styled,
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum BadgeVariant {
|
||||
#[default]
|
||||
Primary,
|
||||
Secondary,
|
||||
Outline,
|
||||
Destructive,
|
||||
Custom {
|
||||
color: Hsla,
|
||||
foreground: Hsla,
|
||||
border: Hsla,
|
||||
},
|
||||
}
|
||||
impl BadgeVariant {
|
||||
fn bg(&self, cx: &gpui::WindowContext) -> Hsla {
|
||||
match self {
|
||||
Self::Primary => cx.theme().primary,
|
||||
Self::Secondary => cx.theme().secondary,
|
||||
Self::Outline => gpui::transparent_black(),
|
||||
Self::Destructive => cx.theme().destructive,
|
||||
Self::Custom { color, .. } => *color,
|
||||
}
|
||||
}
|
||||
|
||||
fn border(&self, cx: &gpui::WindowContext) -> Hsla {
|
||||
match self {
|
||||
Self::Primary => cx.theme().primary,
|
||||
Self::Secondary => cx.theme().secondary,
|
||||
Self::Outline => cx.theme().border,
|
||||
Self::Destructive => cx.theme().destructive,
|
||||
Self::Custom { border, .. } => *border,
|
||||
}
|
||||
}
|
||||
|
||||
fn fg(&self, cx: &gpui::WindowContext) -> Hsla {
|
||||
match self {
|
||||
Self::Primary => cx.theme().primary_foreground,
|
||||
Self::Secondary => cx.theme().secondary_foreground,
|
||||
Self::Outline => cx.theme().foreground,
|
||||
Self::Destructive => cx.theme().destructive_foreground,
|
||||
Self::Custom { foreground, .. } => *foreground,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Badge is a small status indicator for UI elements.
|
||||
///
|
||||
/// Only support: Medium, Small
|
||||
#[derive(IntoElement)]
|
||||
pub struct Badge {
|
||||
base: Div,
|
||||
veriant: BadgeVariant,
|
||||
size: Size,
|
||||
}
|
||||
impl Badge {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
base: div().flex().items_center().rounded_md().border_1(),
|
||||
veriant: BadgeVariant::default(),
|
||||
size: Size::Medium,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_variant(mut self, variant: BadgeVariant) -> Self {
|
||||
self.veriant = variant;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn primary() -> Self {
|
||||
Self::new().with_variant(BadgeVariant::Primary)
|
||||
}
|
||||
|
||||
pub fn secondary() -> Self {
|
||||
Self::new().with_variant(BadgeVariant::Secondary)
|
||||
}
|
||||
|
||||
pub fn outline() -> Self {
|
||||
Self::new().with_variant(BadgeVariant::Outline)
|
||||
}
|
||||
|
||||
pub fn destructive() -> Self {
|
||||
Self::new().with_variant(BadgeVariant::Destructive)
|
||||
}
|
||||
|
||||
pub fn custom(color: Hsla, foreground: Hsla, border: Hsla) -> Self {
|
||||
Self::new().with_variant(BadgeVariant::Custom {
|
||||
color,
|
||||
foreground,
|
||||
border,
|
||||
})
|
||||
}
|
||||
}
|
||||
impl Sizable for Badge {
|
||||
fn with_size(mut self, size: impl Into<Size>) -> Self {
|
||||
self.size = size.into();
|
||||
self
|
||||
}
|
||||
}
|
||||
impl ParentElement for Badge {
|
||||
fn extend(&mut self, elements: impl IntoIterator<Item = gpui::AnyElement>) {
|
||||
self.base.extend(elements);
|
||||
}
|
||||
}
|
||||
impl RenderOnce for Badge {
|
||||
fn render(self, cx: &mut gpui::WindowContext) -> impl IntoElement {
|
||||
self.base
|
||||
.line_height(relative(1.3))
|
||||
.map(|this| match self.size {
|
||||
Size::XSmall | Size::Small => this.text_xs().px_1p5().py_0(),
|
||||
_ => this.text_xs().px_2p5().py_0p5(),
|
||||
})
|
||||
.bg(self.veriant.bg(cx))
|
||||
.text_color(self.veriant.fg(cx))
|
||||
.border_color(self.veriant.border(cx))
|
||||
.hover(|this| this.opacity(0.9))
|
||||
}
|
||||
}
|
||||
118
crates/ui/src/breadcrumb.rs
Normal file
118
crates/ui/src/breadcrumb.rs
Normal file
@@ -0,0 +1,118 @@
|
||||
use std::rc::Rc;
|
||||
|
||||
use gpui::{
|
||||
div, prelude::FluentBuilder as _, ClickEvent, ElementId, InteractiveElement as _, IntoElement,
|
||||
ParentElement, RenderOnce, SharedString, StatefulInteractiveElement, Styled, WindowContext,
|
||||
};
|
||||
|
||||
use crate::{h_flex, theme::ActiveTheme, Icon, IconName};
|
||||
|
||||
#[derive(IntoElement)]
|
||||
pub struct Breadcrumb {
|
||||
items: Vec<BreadcrumbItem>,
|
||||
}
|
||||
|
||||
#[derive(IntoElement)]
|
||||
pub struct BreadcrumbItem {
|
||||
id: ElementId,
|
||||
text: SharedString,
|
||||
on_click: Option<Rc<dyn Fn(&ClickEvent, &mut WindowContext)>>,
|
||||
disabled: bool,
|
||||
is_last: bool,
|
||||
}
|
||||
|
||||
impl BreadcrumbItem {
|
||||
pub fn new(id: impl Into<ElementId>, text: impl Into<SharedString>) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
text: text.into(),
|
||||
on_click: None,
|
||||
disabled: false,
|
||||
is_last: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn disabled(mut self, disabled: bool) -> Self {
|
||||
self.disabled = disabled;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn on_click(
|
||||
mut self,
|
||||
on_click: impl Fn(&ClickEvent, &mut WindowContext) + 'static,
|
||||
) -> Self {
|
||||
self.on_click = Some(Rc::new(on_click));
|
||||
self
|
||||
}
|
||||
|
||||
/// For internal use only.
|
||||
fn is_last(mut self, is_last: bool) -> Self {
|
||||
self.is_last = is_last;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for BreadcrumbItem {
|
||||
fn render(self, cx: &mut WindowContext) -> impl IntoElement {
|
||||
div()
|
||||
.id(self.id)
|
||||
.child(self.text)
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.when(self.is_last, |this| this.text_color(cx.theme().foreground))
|
||||
.when(self.disabled, |this| {
|
||||
this.text_color(cx.theme().muted_foreground)
|
||||
})
|
||||
.when(!self.disabled, |this| {
|
||||
this.when_some(self.on_click, |this, on_click| {
|
||||
this.cursor_pointer().on_click(move |event, cx| {
|
||||
on_click(event, cx);
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Breadcrumb {
|
||||
pub fn new() -> Self {
|
||||
Self { items: Vec::new() }
|
||||
}
|
||||
|
||||
/// Add an item to the breadcrumb.
|
||||
pub fn item(mut self, item: BreadcrumbItem) -> Self {
|
||||
self.items.push(item);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(IntoElement)]
|
||||
struct BreadcrumbSeparator;
|
||||
impl RenderOnce for BreadcrumbSeparator {
|
||||
fn render(self, cx: &mut WindowContext) -> impl IntoElement {
|
||||
Icon::new(IconName::ChevronRight)
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.size_3p5()
|
||||
.into_any_element()
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for Breadcrumb {
|
||||
fn render(self, cx: &mut WindowContext) -> impl IntoElement {
|
||||
let items_count = self.items.len();
|
||||
|
||||
let mut children = vec![];
|
||||
for (ix, item) in self.items.into_iter().enumerate() {
|
||||
let is_last = ix == items_count - 1;
|
||||
|
||||
children.push(item.is_last(is_last).into_any_element());
|
||||
if !is_last {
|
||||
children.push(BreadcrumbSeparator.into_any_element());
|
||||
}
|
||||
}
|
||||
|
||||
h_flex()
|
||||
.gap_1p5()
|
||||
.text_sm()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.children(children)
|
||||
}
|
||||
}
|
||||
667
crates/ui/src/button.rs
Normal file
667
crates/ui/src/button.rs
Normal file
@@ -0,0 +1,667 @@
|
||||
use crate::{
|
||||
h_flex,
|
||||
indicator::Indicator,
|
||||
theme::{ActiveTheme, Colorize as _},
|
||||
tooltip::Tooltip,
|
||||
Disableable, Icon, Selectable, Sizable, Size,
|
||||
};
|
||||
use gpui::{
|
||||
div, prelude::FluentBuilder as _, px, relative, AnyElement, ClickEvent, Corners, Div, Edges,
|
||||
ElementId, Hsla, InteractiveElement, IntoElement, MouseButton, ParentElement, Pixels,
|
||||
RenderOnce, SharedString, StatefulInteractiveElement as _, Styled, WindowContext,
|
||||
};
|
||||
|
||||
pub enum ButtonRounded {
|
||||
None,
|
||||
Small,
|
||||
Medium,
|
||||
Large,
|
||||
Size(Pixels),
|
||||
}
|
||||
|
||||
impl From<Pixels> for ButtonRounded {
|
||||
fn from(px: Pixels) -> Self {
|
||||
ButtonRounded::Size(px)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
pub struct ButtonCustomVariant {
|
||||
color: Hsla,
|
||||
foreground: Hsla,
|
||||
border: Hsla,
|
||||
shadow: bool,
|
||||
hover: Hsla,
|
||||
active: Hsla,
|
||||
}
|
||||
|
||||
pub trait ButtonVariants: Sized {
|
||||
fn with_variant(self, variant: ButtonVariant) -> Self;
|
||||
|
||||
/// With the primary style for the Button.
|
||||
fn primary(self) -> Self {
|
||||
self.with_variant(ButtonVariant::Primary)
|
||||
}
|
||||
|
||||
/// With the danger style for the Button.
|
||||
fn danger(self) -> Self {
|
||||
self.with_variant(ButtonVariant::Danger)
|
||||
}
|
||||
|
||||
/// With the outline style for the Button.
|
||||
fn outline(self) -> Self {
|
||||
self.with_variant(ButtonVariant::Outline)
|
||||
}
|
||||
|
||||
/// With the ghost style for the Button.
|
||||
fn ghost(self) -> Self {
|
||||
self.with_variant(ButtonVariant::Ghost)
|
||||
}
|
||||
|
||||
/// With the link style for the Button.
|
||||
fn link(self) -> Self {
|
||||
self.with_variant(ButtonVariant::Link)
|
||||
}
|
||||
|
||||
/// With the text style for the Button, it will no padding look like a normal text.
|
||||
fn text(self) -> Self {
|
||||
self.with_variant(ButtonVariant::Text)
|
||||
}
|
||||
|
||||
/// With the custom style for the Button.
|
||||
fn custom(self, style: ButtonCustomVariant) -> Self {
|
||||
self.with_variant(ButtonVariant::Custom(style))
|
||||
}
|
||||
}
|
||||
|
||||
impl ButtonCustomVariant {
|
||||
pub fn new(cx: &WindowContext) -> Self {
|
||||
Self {
|
||||
color: cx.theme().secondary,
|
||||
foreground: cx.theme().secondary_foreground,
|
||||
border: cx.theme().border,
|
||||
hover: cx.theme().secondary_hover,
|
||||
active: cx.theme().secondary_active,
|
||||
shadow: true,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn color(mut self, color: Hsla) -> Self {
|
||||
self.color = color;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn foreground(mut self, color: Hsla) -> Self {
|
||||
self.foreground = color;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn border(mut self, color: Hsla) -> Self {
|
||||
self.border = color;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn hover(mut self, color: Hsla) -> Self {
|
||||
self.hover = color;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn active(mut self, color: Hsla) -> Self {
|
||||
self.active = color;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn shadow(mut self, shadow: bool) -> Self {
|
||||
self.shadow = shadow;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// The veriant of the Button.
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ButtonVariant {
|
||||
Primary,
|
||||
Secondary,
|
||||
Danger,
|
||||
Outline,
|
||||
Ghost,
|
||||
Link,
|
||||
Text,
|
||||
Custom(ButtonCustomVariant),
|
||||
}
|
||||
|
||||
impl Default for ButtonVariant {
|
||||
fn default() -> Self {
|
||||
Self::Secondary
|
||||
}
|
||||
}
|
||||
|
||||
impl ButtonVariant {
|
||||
fn is_link(&self) -> bool {
|
||||
matches!(self, Self::Link)
|
||||
}
|
||||
|
||||
fn is_text(&self) -> bool {
|
||||
matches!(self, Self::Text)
|
||||
}
|
||||
|
||||
fn no_padding(&self) -> bool {
|
||||
self.is_link() || self.is_text()
|
||||
}
|
||||
}
|
||||
|
||||
/// A Button element.
|
||||
#[derive(IntoElement)]
|
||||
pub struct Button {
|
||||
pub base: Div,
|
||||
id: ElementId,
|
||||
icon: Option<Icon>,
|
||||
label: Option<SharedString>,
|
||||
children: Vec<AnyElement>,
|
||||
disabled: bool,
|
||||
pub(crate) selected: bool,
|
||||
variant: ButtonVariant,
|
||||
rounded: ButtonRounded,
|
||||
border_corners: Corners<bool>,
|
||||
border_edges: Edges<bool>,
|
||||
size: Size,
|
||||
compact: bool,
|
||||
tooltip: Option<SharedString>,
|
||||
on_click: Option<Box<dyn Fn(&ClickEvent, &mut WindowContext) + 'static>>,
|
||||
pub(crate) stop_propagation: bool,
|
||||
loading: bool,
|
||||
loading_icon: Option<Icon>,
|
||||
}
|
||||
|
||||
impl From<Button> for AnyElement {
|
||||
fn from(button: Button) -> Self {
|
||||
button.into_any_element()
|
||||
}
|
||||
}
|
||||
|
||||
impl Button {
|
||||
pub fn new(id: impl Into<ElementId>) -> Self {
|
||||
Self {
|
||||
base: div().flex_shrink_0(),
|
||||
id: id.into(),
|
||||
icon: None,
|
||||
label: None,
|
||||
disabled: false,
|
||||
selected: false,
|
||||
variant: ButtonVariant::default(),
|
||||
rounded: ButtonRounded::Medium,
|
||||
border_corners: Corners::all(true),
|
||||
border_edges: Edges::all(true),
|
||||
size: Size::Medium,
|
||||
tooltip: None,
|
||||
on_click: None,
|
||||
stop_propagation: true,
|
||||
loading: false,
|
||||
compact: false,
|
||||
children: Vec::new(),
|
||||
loading_icon: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the border radius of the Button.
|
||||
pub fn rounded(mut self, rounded: impl Into<ButtonRounded>) -> Self {
|
||||
self.rounded = rounded.into();
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the border corners side of the Button.
|
||||
pub(crate) fn border_corners(mut self, corners: impl Into<Corners<bool>>) -> Self {
|
||||
self.border_corners = corners.into();
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the border edges of the Button.
|
||||
pub(crate) fn border_edges(mut self, edges: impl Into<Edges<bool>>) -> Self {
|
||||
self.border_edges = edges.into();
|
||||
self
|
||||
}
|
||||
|
||||
/// Set label to the Button, if no label is set, the button will be in Icon Button mode.
|
||||
pub fn label(mut self, label: impl Into<SharedString>) -> Self {
|
||||
self.label = Some(label.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the icon of the button, if the Button have no label, the button well in Icon Button mode.
|
||||
pub fn icon(mut self, icon: impl Into<Icon>) -> Self {
|
||||
self.icon = Some(icon.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the tooltip of the button.
|
||||
pub fn tooltip(mut self, tooltip: impl Into<SharedString>) -> Self {
|
||||
self.tooltip = Some(tooltip.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// Set true to show the loading indicator.
|
||||
pub fn loading(mut self, loading: bool) -> Self {
|
||||
self.loading = loading;
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the button to compact mode, then padding will be reduced.
|
||||
pub fn compact(mut self) -> Self {
|
||||
self.compact = true;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn on_click(mut self, handler: impl Fn(&ClickEvent, &mut WindowContext) + 'static) -> Self {
|
||||
self.on_click = Some(Box::new(handler));
|
||||
self
|
||||
}
|
||||
|
||||
pub fn stop_propagation(mut self, val: bool) -> Self {
|
||||
self.stop_propagation = val;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn loading_icon(mut self, icon: impl Into<Icon>) -> Self {
|
||||
self.loading_icon = Some(icon.into());
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl Disableable for Button {
|
||||
fn disabled(mut self, disabled: bool) -> Self {
|
||||
self.disabled = disabled;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl Selectable for Button {
|
||||
fn element_id(&self) -> &ElementId {
|
||||
&self.id
|
||||
}
|
||||
|
||||
fn selected(mut self, selected: bool) -> Self {
|
||||
self.selected = selected;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl Sizable for Button {
|
||||
fn with_size(mut self, size: impl Into<Size>) -> Self {
|
||||
self.size = size.into();
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl ButtonVariants for Button {
|
||||
fn with_variant(mut self, variant: ButtonVariant) -> Self {
|
||||
self.variant = variant;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl Styled for Button {
|
||||
fn style(&mut self) -> &mut gpui::StyleRefinement {
|
||||
self.base.style()
|
||||
}
|
||||
}
|
||||
|
||||
impl ParentElement for Button {
|
||||
fn extend(&mut self, elements: impl IntoIterator<Item = gpui::AnyElement>) {
|
||||
self.children.extend(elements)
|
||||
}
|
||||
}
|
||||
|
||||
impl InteractiveElement for Button {
|
||||
fn interactivity(&mut self) -> &mut gpui::Interactivity {
|
||||
self.base.interactivity()
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for Button {
|
||||
fn render(self, cx: &mut WindowContext) -> impl IntoElement {
|
||||
let style: ButtonVariant = self.variant;
|
||||
let normal_style = style.normal(cx);
|
||||
let icon_size = match self.size {
|
||||
Size::Size(v) => Size::Size(v * 0.75),
|
||||
_ => self.size,
|
||||
};
|
||||
|
||||
self.base
|
||||
.id(self.id)
|
||||
.flex()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.cursor_pointer()
|
||||
.overflow_hidden()
|
||||
.when(cx.theme().shadow && normal_style.shadow, |this| {
|
||||
this.shadow_sm()
|
||||
})
|
||||
.when(!style.no_padding(), |this| {
|
||||
if self.label.is_none() && self.children.is_empty() {
|
||||
// Icon Button
|
||||
match self.size {
|
||||
Size::Size(px) => this.size(px),
|
||||
Size::XSmall => this.size_5(),
|
||||
Size::Small => this.size_6(),
|
||||
Size::Large | Size::Medium => this.size_8(),
|
||||
}
|
||||
} else {
|
||||
// Normal Button
|
||||
match self.size {
|
||||
Size::Size(size) => this.px(size * 0.2),
|
||||
Size::XSmall => this.h_5().px_1(),
|
||||
Size::Small => this.h_6().px_3().when(self.compact, |this| this.px_1p5()),
|
||||
_ => this.h_8().px_4().when(self.compact, |this| this.px_2()),
|
||||
}
|
||||
}
|
||||
})
|
||||
.when(
|
||||
self.border_corners.top_left && self.border_corners.bottom_left,
|
||||
|this| match self.rounded {
|
||||
ButtonRounded::Small => this.rounded_l(px(cx.theme().radius * 0.5)),
|
||||
ButtonRounded::Medium => this.rounded_l(px(cx.theme().radius)),
|
||||
ButtonRounded::Large => this.rounded_l(px(cx.theme().radius * 2.0)),
|
||||
ButtonRounded::Size(px) => this.rounded_l(px),
|
||||
ButtonRounded::None => this.rounded_none(),
|
||||
},
|
||||
)
|
||||
.when(
|
||||
self.border_corners.top_right && self.border_corners.bottom_right,
|
||||
|this| match self.rounded {
|
||||
ButtonRounded::Small => this.rounded_r(px(cx.theme().radius * 0.5)),
|
||||
ButtonRounded::Medium => this.rounded_r(px(cx.theme().radius)),
|
||||
ButtonRounded::Large => this.rounded_r(px(cx.theme().radius * 2.0)),
|
||||
ButtonRounded::Size(px) => this.rounded_r(px),
|
||||
ButtonRounded::None => this.rounded_none(),
|
||||
},
|
||||
)
|
||||
.when(self.border_edges.left, |this| this.border_l_1())
|
||||
.when(self.border_edges.right, |this| this.border_r_1())
|
||||
.when(self.border_edges.top, |this| this.border_t_1())
|
||||
.when(self.border_edges.bottom, |this| this.border_b_1())
|
||||
.text_color(normal_style.fg)
|
||||
.when(self.selected, |this| {
|
||||
let selected_style = style.selected(cx);
|
||||
this.bg(selected_style.bg)
|
||||
.border_color(selected_style.border)
|
||||
.text_color(selected_style.fg)
|
||||
})
|
||||
.when(!self.disabled && !self.selected, |this| {
|
||||
this.border_color(normal_style.border)
|
||||
.bg(normal_style.bg)
|
||||
.when(normal_style.underline, |this| this.text_decoration_1())
|
||||
.hover(|this| {
|
||||
let hover_style = style.hovered(cx);
|
||||
this.bg(hover_style.bg)
|
||||
.border_color(hover_style.border)
|
||||
.text_color(crate::red_400())
|
||||
})
|
||||
.active(|this| {
|
||||
let active_style = style.active(cx);
|
||||
this.bg(active_style.bg)
|
||||
.border_color(active_style.border)
|
||||
.text_color(active_style.fg)
|
||||
})
|
||||
})
|
||||
.when_some(
|
||||
self.on_click.filter(|_| !self.disabled && !self.loading),
|
||||
|this, on_click| {
|
||||
let stop_propagation = self.stop_propagation;
|
||||
this.on_mouse_down(MouseButton::Left, move |_, cx| {
|
||||
cx.prevent_default();
|
||||
if stop_propagation {
|
||||
cx.stop_propagation();
|
||||
}
|
||||
})
|
||||
.on_click(move |event, cx| {
|
||||
(on_click)(event, cx);
|
||||
})
|
||||
},
|
||||
)
|
||||
.when(self.disabled, |this| {
|
||||
let disabled_style = style.disabled(cx);
|
||||
this.cursor_not_allowed()
|
||||
.bg(disabled_style.bg)
|
||||
.text_color(disabled_style.fg)
|
||||
.border_color(disabled_style.border)
|
||||
.shadow_none()
|
||||
})
|
||||
.child({
|
||||
h_flex()
|
||||
.id("label")
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.map(|this| match self.size {
|
||||
Size::XSmall => this.gap_1().text_xs(),
|
||||
Size::Small => this.gap_1().text_sm(),
|
||||
_ => this.gap_2().text_base(),
|
||||
})
|
||||
.when(!self.loading, |this| {
|
||||
this.when_some(self.icon, |this, icon| {
|
||||
this.child(icon.with_size(icon_size))
|
||||
})
|
||||
})
|
||||
.when(self.loading, |this| {
|
||||
this.child(
|
||||
Indicator::new()
|
||||
.with_size(self.size)
|
||||
.when_some(self.loading_icon, |this, icon| this.icon(icon)),
|
||||
)
|
||||
})
|
||||
.when_some(self.label, |this, label| {
|
||||
this.child(div().flex_none().line_height(relative(1.)).child(label))
|
||||
})
|
||||
.children(self.children)
|
||||
})
|
||||
.when(self.loading, |this| this.bg(normal_style.bg.opacity(0.8)))
|
||||
.when_some(self.tooltip.clone(), |this, tooltip| {
|
||||
this.tooltip(move |cx| Tooltip::new(tooltip.clone(), cx))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
struct ButtonVariantStyle {
|
||||
bg: Hsla,
|
||||
border: Hsla,
|
||||
fg: Hsla,
|
||||
underline: bool,
|
||||
shadow: bool,
|
||||
}
|
||||
|
||||
impl ButtonVariant {
|
||||
fn bg_color(&self, cx: &WindowContext) -> Hsla {
|
||||
match self {
|
||||
ButtonVariant::Primary => cx.theme().primary,
|
||||
ButtonVariant::Secondary => cx.theme().secondary,
|
||||
ButtonVariant::Danger => cx.theme().destructive,
|
||||
ButtonVariant::Outline
|
||||
| ButtonVariant::Ghost
|
||||
| ButtonVariant::Link
|
||||
| ButtonVariant::Text => cx.theme().transparent,
|
||||
ButtonVariant::Custom(colors) => colors.color,
|
||||
}
|
||||
}
|
||||
|
||||
fn text_color(&self, cx: &WindowContext) -> Hsla {
|
||||
match self {
|
||||
ButtonVariant::Primary => cx.theme().primary_foreground,
|
||||
ButtonVariant::Secondary | ButtonVariant::Outline | ButtonVariant::Ghost => {
|
||||
cx.theme().secondary_foreground
|
||||
}
|
||||
ButtonVariant::Danger => cx.theme().destructive_foreground,
|
||||
ButtonVariant::Link => cx.theme().link,
|
||||
ButtonVariant::Text => cx.theme().foreground,
|
||||
ButtonVariant::Custom(colors) => colors.foreground,
|
||||
}
|
||||
}
|
||||
|
||||
fn border_color(&self, cx: &WindowContext) -> Hsla {
|
||||
match self {
|
||||
ButtonVariant::Primary => cx.theme().primary,
|
||||
ButtonVariant::Secondary => cx.theme().border,
|
||||
ButtonVariant::Danger => cx.theme().destructive,
|
||||
ButtonVariant::Outline => cx.theme().border,
|
||||
ButtonVariant::Ghost | ButtonVariant::Link | ButtonVariant::Text => {
|
||||
cx.theme().transparent
|
||||
}
|
||||
ButtonVariant::Custom(colors) => colors.border,
|
||||
}
|
||||
}
|
||||
|
||||
fn underline(&self, _: &WindowContext) -> bool {
|
||||
match self {
|
||||
ButtonVariant::Link => true,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn shadow(&self, _: &WindowContext) -> bool {
|
||||
match self {
|
||||
ButtonVariant::Primary | ButtonVariant::Secondary | ButtonVariant::Danger => true,
|
||||
ButtonVariant::Custom(c) => c.shadow,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn normal(&self, cx: &WindowContext) -> ButtonVariantStyle {
|
||||
let bg = self.bg_color(cx);
|
||||
let border = self.border_color(cx);
|
||||
let fg = self.text_color(cx);
|
||||
let underline = self.underline(cx);
|
||||
let shadow = self.shadow(cx);
|
||||
|
||||
ButtonVariantStyle {
|
||||
bg,
|
||||
border,
|
||||
fg,
|
||||
underline,
|
||||
shadow,
|
||||
}
|
||||
}
|
||||
|
||||
fn hovered(&self, cx: &WindowContext) -> ButtonVariantStyle {
|
||||
let bg = match self {
|
||||
ButtonVariant::Primary => cx.theme().primary_hover,
|
||||
ButtonVariant::Secondary | ButtonVariant::Outline => cx.theme().secondary_hover,
|
||||
ButtonVariant::Danger => cx.theme().destructive_hover,
|
||||
ButtonVariant::Ghost => {
|
||||
if cx.theme().mode.is_dark() {
|
||||
cx.theme().secondary.lighten(0.1).opacity(0.8)
|
||||
} else {
|
||||
cx.theme().secondary.darken(0.1).opacity(0.8)
|
||||
}
|
||||
}
|
||||
ButtonVariant::Link => cx.theme().transparent,
|
||||
ButtonVariant::Text => cx.theme().transparent,
|
||||
ButtonVariant::Custom(colors) => colors.hover,
|
||||
};
|
||||
let border = self.border_color(cx);
|
||||
let fg = match self {
|
||||
ButtonVariant::Link => cx.theme().link_hover,
|
||||
_ => self.text_color(cx),
|
||||
};
|
||||
let underline = self.underline(cx);
|
||||
let shadow = self.shadow(cx);
|
||||
|
||||
ButtonVariantStyle {
|
||||
bg,
|
||||
border,
|
||||
fg,
|
||||
underline,
|
||||
shadow,
|
||||
}
|
||||
}
|
||||
|
||||
fn active(&self, cx: &WindowContext) -> ButtonVariantStyle {
|
||||
let bg = match self {
|
||||
ButtonVariant::Primary => cx.theme().primary_active,
|
||||
ButtonVariant::Secondary | ButtonVariant::Outline | ButtonVariant::Ghost => {
|
||||
cx.theme().secondary_active
|
||||
}
|
||||
ButtonVariant::Danger => cx.theme().destructive_active,
|
||||
ButtonVariant::Link => cx.theme().transparent,
|
||||
ButtonVariant::Text => cx.theme().transparent,
|
||||
ButtonVariant::Custom(colors) => colors.active,
|
||||
};
|
||||
let border = self.border_color(cx);
|
||||
let fg = match self {
|
||||
ButtonVariant::Link => cx.theme().link_active,
|
||||
ButtonVariant::Text => cx.theme().foreground.opacity(0.7),
|
||||
_ => self.text_color(cx),
|
||||
};
|
||||
let underline = self.underline(cx);
|
||||
let shadow = self.shadow(cx);
|
||||
|
||||
ButtonVariantStyle {
|
||||
bg,
|
||||
border,
|
||||
fg,
|
||||
underline,
|
||||
shadow,
|
||||
}
|
||||
}
|
||||
|
||||
fn selected(&self, cx: &WindowContext) -> ButtonVariantStyle {
|
||||
let bg = match self {
|
||||
ButtonVariant::Primary => cx.theme().primary_active,
|
||||
ButtonVariant::Secondary | ButtonVariant::Outline | ButtonVariant::Ghost => {
|
||||
cx.theme().secondary_active
|
||||
}
|
||||
ButtonVariant::Danger => cx.theme().destructive_active,
|
||||
ButtonVariant::Link => cx.theme().transparent,
|
||||
ButtonVariant::Text => cx.theme().transparent,
|
||||
ButtonVariant::Custom(colors) => colors.active,
|
||||
};
|
||||
let border = self.border_color(cx);
|
||||
let fg = match self {
|
||||
ButtonVariant::Link => cx.theme().link_active,
|
||||
ButtonVariant::Text => cx.theme().foreground.opacity(0.7),
|
||||
_ => self.text_color(cx),
|
||||
};
|
||||
let underline = self.underline(cx);
|
||||
let shadow = self.shadow(cx);
|
||||
|
||||
ButtonVariantStyle {
|
||||
bg,
|
||||
border,
|
||||
fg,
|
||||
underline,
|
||||
shadow,
|
||||
}
|
||||
}
|
||||
|
||||
fn disabled(&self, cx: &WindowContext) -> ButtonVariantStyle {
|
||||
let bg = match self {
|
||||
ButtonVariant::Link
|
||||
| ButtonVariant::Ghost
|
||||
| ButtonVariant::Outline
|
||||
| ButtonVariant::Text => cx.theme().transparent,
|
||||
ButtonVariant::Primary => cx.theme().primary.opacity(0.15),
|
||||
ButtonVariant::Danger => cx.theme().destructive.opacity(0.15),
|
||||
ButtonVariant::Secondary => cx.theme().secondary.opacity(1.5),
|
||||
ButtonVariant::Custom(style) => style.color.opacity(0.15),
|
||||
};
|
||||
let fg = match self {
|
||||
ButtonVariant::Link | ButtonVariant::Text | ButtonVariant::Ghost => {
|
||||
cx.theme().link.grayscale()
|
||||
}
|
||||
_ => cx.theme().secondary_foreground.opacity(0.5).grayscale(),
|
||||
};
|
||||
|
||||
let border = match self {
|
||||
ButtonVariant::Outline => cx.theme().border.opacity(0.5),
|
||||
_ => bg,
|
||||
};
|
||||
|
||||
let underline = self.underline(cx);
|
||||
let shadow = false;
|
||||
|
||||
ButtonVariantStyle {
|
||||
bg,
|
||||
border,
|
||||
fg,
|
||||
underline,
|
||||
shadow,
|
||||
}
|
||||
}
|
||||
}
|
||||
198
crates/ui/src/button_group.rs
Normal file
198
crates/ui/src/button_group.rs
Normal file
@@ -0,0 +1,198 @@
|
||||
use gpui::{
|
||||
div, prelude::FluentBuilder as _, Corners, Div, Edges, ElementId, InteractiveElement,
|
||||
IntoElement, ParentElement, RenderOnce, StatefulInteractiveElement as _, Styled, WindowContext,
|
||||
};
|
||||
use std::{cell::Cell, rc::Rc};
|
||||
|
||||
use crate::{
|
||||
button::{Button, ButtonVariant, ButtonVariants},
|
||||
Disableable, Sizable, Size,
|
||||
};
|
||||
|
||||
/// A ButtonGroup element, to wrap multiple buttons in a group.
|
||||
#[derive(IntoElement)]
|
||||
pub struct ButtonGroup {
|
||||
pub base: Div,
|
||||
id: ElementId,
|
||||
children: Vec<Button>,
|
||||
multiple: bool,
|
||||
disabled: bool,
|
||||
|
||||
// The button props
|
||||
compact: Option<bool>,
|
||||
variant: Option<ButtonVariant>,
|
||||
size: Option<Size>,
|
||||
|
||||
on_click: Option<Box<dyn Fn(&Vec<usize>, &mut WindowContext) + 'static>>,
|
||||
}
|
||||
|
||||
impl Disableable for ButtonGroup {
|
||||
fn disabled(mut self, disabled: bool) -> Self {
|
||||
self.disabled = disabled;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl ButtonGroup {
|
||||
/// Creates a new ButtonGroup.
|
||||
pub fn new(id: impl Into<ElementId>) -> Self {
|
||||
Self {
|
||||
base: div(),
|
||||
children: Vec::new(),
|
||||
id: id.into(),
|
||||
variant: None,
|
||||
size: None,
|
||||
compact: None,
|
||||
multiple: false,
|
||||
disabled: false,
|
||||
on_click: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Adds a button as a child to the ButtonGroup.
|
||||
pub fn child(mut self, child: Button) -> Self {
|
||||
self.children.push(child.disabled(self.disabled));
|
||||
self
|
||||
}
|
||||
|
||||
/// With the multiple selection mode.
|
||||
pub fn multiple(mut self, multiple: bool) -> Self {
|
||||
self.multiple = multiple;
|
||||
self
|
||||
}
|
||||
|
||||
/// With the compact mode for the ButtonGroup.
|
||||
pub fn compact(mut self) -> Self {
|
||||
self.compact = Some(true);
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets the on_click handler for the ButtonGroup.
|
||||
///
|
||||
/// The handler first argument is a vector of the selected button indices.
|
||||
pub fn on_click(mut self, handler: impl Fn(&Vec<usize>, &mut WindowContext) + 'static) -> Self {
|
||||
self.on_click = Some(Box::new(handler));
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl Sizable for ButtonGroup {
|
||||
fn with_size(mut self, size: impl Into<Size>) -> Self {
|
||||
self.size = Some(size.into());
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl Styled for ButtonGroup {
|
||||
fn style(&mut self) -> &mut gpui::StyleRefinement {
|
||||
self.base.style()
|
||||
}
|
||||
}
|
||||
|
||||
impl ButtonVariants for ButtonGroup {
|
||||
fn with_variant(mut self, variant: ButtonVariant) -> Self {
|
||||
self.variant = Some(variant);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for ButtonGroup {
|
||||
fn render(self, _cx: &mut WindowContext) -> impl IntoElement {
|
||||
let children_len = self.children.len();
|
||||
let mut selected_ixs: Vec<usize> = Vec::new();
|
||||
let state = Rc::new(Cell::new(None));
|
||||
|
||||
for (ix, child) in self.children.iter().enumerate() {
|
||||
if child.selected {
|
||||
selected_ixs.push(ix);
|
||||
}
|
||||
}
|
||||
|
||||
self.base
|
||||
.id(self.id)
|
||||
.flex()
|
||||
.items_center()
|
||||
.children(
|
||||
self.children
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(child_index, child)| {
|
||||
let state = Rc::clone(&state);
|
||||
let child = if children_len == 1 {
|
||||
child
|
||||
} else if child_index == 0 {
|
||||
// First
|
||||
child
|
||||
.border_corners(Corners {
|
||||
top_left: true,
|
||||
top_right: false,
|
||||
bottom_left: true,
|
||||
bottom_right: false,
|
||||
})
|
||||
.border_edges(Edges {
|
||||
left: true,
|
||||
top: true,
|
||||
right: true,
|
||||
bottom: true,
|
||||
})
|
||||
} else if child_index == children_len - 1 {
|
||||
// Last
|
||||
child
|
||||
.border_edges(Edges {
|
||||
left: false,
|
||||
top: true,
|
||||
right: true,
|
||||
bottom: true,
|
||||
})
|
||||
.border_corners(Corners {
|
||||
top_left: false,
|
||||
top_right: true,
|
||||
bottom_left: false,
|
||||
bottom_right: true,
|
||||
})
|
||||
} else {
|
||||
// Middle
|
||||
child
|
||||
.border_corners(Corners::all(false))
|
||||
.border_edges(Edges {
|
||||
left: false,
|
||||
top: true,
|
||||
right: true,
|
||||
bottom: true,
|
||||
})
|
||||
}
|
||||
.stop_propagation(false)
|
||||
.when_some(self.size, |this, size| this.with_size(size))
|
||||
.when_some(self.variant, |this, variant| this.with_variant(variant))
|
||||
.when_some(self.compact, |this, _| this.compact())
|
||||
.on_click(move |_, _| {
|
||||
state.set(Some(child_index));
|
||||
});
|
||||
|
||||
child
|
||||
}),
|
||||
)
|
||||
.when_some(
|
||||
self.on_click.filter(|_| !self.disabled),
|
||||
move |this, on_click| {
|
||||
this.on_click(move |_, cx| {
|
||||
let mut selected_ixs = selected_ixs.clone();
|
||||
if let Some(ix) = state.get() {
|
||||
if self.multiple {
|
||||
if let Some(pos) = selected_ixs.iter().position(|&i| i == ix) {
|
||||
selected_ixs.remove(pos);
|
||||
} else {
|
||||
selected_ixs.push(ix);
|
||||
}
|
||||
} else {
|
||||
selected_ixs.clear();
|
||||
selected_ixs.push(ix);
|
||||
}
|
||||
}
|
||||
|
||||
on_click(&selected_ixs, cx);
|
||||
})
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
131
crates/ui/src/checkbox.rs
Normal file
131
crates/ui/src/checkbox.rs
Normal file
@@ -0,0 +1,131 @@
|
||||
use crate::{h_flex, theme::ActiveTheme, v_flex, Disableable, IconName, Selectable};
|
||||
use gpui::{
|
||||
div, prelude::FluentBuilder as _, relative, svg, ElementId, InteractiveElement, IntoElement,
|
||||
ParentElement, RenderOnce, SharedString, StatefulInteractiveElement as _, Styled as _,
|
||||
WindowContext,
|
||||
};
|
||||
|
||||
/// A Checkbox element.
|
||||
#[derive(IntoElement)]
|
||||
pub struct Checkbox {
|
||||
id: ElementId,
|
||||
label: Option<SharedString>,
|
||||
checked: bool,
|
||||
disabled: bool,
|
||||
on_click: Option<Box<dyn Fn(&bool, &mut WindowContext) + 'static>>,
|
||||
}
|
||||
|
||||
impl Checkbox {
|
||||
pub fn new(id: impl Into<ElementId>) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
label: None,
|
||||
checked: false,
|
||||
disabled: false,
|
||||
on_click: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn label(mut self, label: impl Into<SharedString>) -> Self {
|
||||
self.label = Some(label.into());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn checked(mut self, checked: bool) -> Self {
|
||||
self.checked = checked;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn on_click(mut self, handler: impl Fn(&bool, &mut WindowContext) + 'static) -> Self {
|
||||
self.on_click = Some(Box::new(handler));
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl Disableable for Checkbox {
|
||||
fn disabled(mut self, disabled: bool) -> Self {
|
||||
self.disabled = disabled;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl Selectable for Checkbox {
|
||||
fn element_id(&self) -> &ElementId {
|
||||
&self.id
|
||||
}
|
||||
|
||||
fn selected(self, selected: bool) -> Self {
|
||||
self.checked(selected)
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for Checkbox {
|
||||
fn render(self, cx: &mut WindowContext) -> impl IntoElement {
|
||||
let (color, icon_color) = if self.disabled {
|
||||
(
|
||||
cx.theme().primary.opacity(0.5),
|
||||
cx.theme().primary_foreground.opacity(0.5),
|
||||
)
|
||||
} else {
|
||||
(cx.theme().primary, cx.theme().primary_foreground)
|
||||
};
|
||||
|
||||
h_flex()
|
||||
.id(self.id)
|
||||
.gap_2()
|
||||
.items_center()
|
||||
.line_height(relative(1.))
|
||||
.child(
|
||||
v_flex()
|
||||
.relative()
|
||||
.border_1()
|
||||
.border_color(color)
|
||||
.rounded_sm()
|
||||
.size_4()
|
||||
.flex_shrink_0()
|
||||
.map(|this| match self.checked {
|
||||
false => this.bg(cx.theme().transparent),
|
||||
_ => this.bg(color),
|
||||
})
|
||||
.child(
|
||||
svg()
|
||||
.absolute()
|
||||
.top_px()
|
||||
.left_px()
|
||||
.size_3()
|
||||
.text_color(icon_color)
|
||||
.map(|this| match self.checked {
|
||||
true => this.path(IconName::Check.path()),
|
||||
_ => this,
|
||||
}),
|
||||
),
|
||||
)
|
||||
.map(|this| {
|
||||
if let Some(label) = self.label {
|
||||
this.text_color(cx.theme().foreground).child(
|
||||
div()
|
||||
.w_full()
|
||||
.overflow_x_hidden()
|
||||
.text_ellipsis()
|
||||
.line_height(relative(1.))
|
||||
.child(label),
|
||||
)
|
||||
} else {
|
||||
this
|
||||
}
|
||||
})
|
||||
.when(self.disabled, |this| {
|
||||
this.cursor_not_allowed()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
})
|
||||
.when_some(
|
||||
self.on_click.filter(|_| !self.disabled),
|
||||
|this, on_click| {
|
||||
this.on_click(move |_, cx| {
|
||||
let checked = !self.checked;
|
||||
on_click(&checked, cx);
|
||||
})
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
152
crates/ui/src/clipboard.rs
Normal file
152
crates/ui/src/clipboard.rs
Normal file
@@ -0,0 +1,152 @@
|
||||
use std::{cell::RefCell, rc::Rc, time::Duration};
|
||||
|
||||
use gpui::{
|
||||
prelude::FluentBuilder, AnyElement, ClipboardItem, Element, ElementId, GlobalElementId,
|
||||
IntoElement, LayoutId, ParentElement, SharedString, Styled, WindowContext,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
button::{Button, ButtonVariants as _},
|
||||
h_flex, IconName, Sizable as _,
|
||||
};
|
||||
|
||||
pub struct Clipboard {
|
||||
id: ElementId,
|
||||
value: SharedString,
|
||||
content_builder: Option<Box<dyn Fn(&mut WindowContext) -> AnyElement>>,
|
||||
copied_callback: Option<Rc<dyn Fn(SharedString, &mut WindowContext)>>,
|
||||
}
|
||||
|
||||
impl Clipboard {
|
||||
pub fn new(id: impl Into<ElementId>) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
value: "".into(),
|
||||
content_builder: None,
|
||||
copied_callback: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn value(mut self, value: impl Into<SharedString>) -> Self {
|
||||
self.value = value.into();
|
||||
self
|
||||
}
|
||||
|
||||
pub fn content<E, F>(mut self, element_builder: F) -> Self
|
||||
where
|
||||
E: IntoElement,
|
||||
F: Fn(&mut WindowContext) -> E + 'static,
|
||||
{
|
||||
self.content_builder = Some(Box::new(move |cx| element_builder(cx).into_any_element()));
|
||||
self
|
||||
}
|
||||
|
||||
pub fn on_copied<F>(mut self, handler: F) -> Self
|
||||
where
|
||||
F: Fn(SharedString, &mut WindowContext) + 'static,
|
||||
{
|
||||
self.copied_callback = Some(Rc::new(handler));
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoElement for Clipboard {
|
||||
type Element = Self;
|
||||
|
||||
fn into_element(self) -> Self::Element {
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct ClipboardState {
|
||||
copied: Rc<RefCell<bool>>,
|
||||
}
|
||||
|
||||
impl Element for Clipboard {
|
||||
type RequestLayoutState = AnyElement;
|
||||
|
||||
type PrepaintState = ();
|
||||
|
||||
fn id(&self) -> Option<ElementId> {
|
||||
Some(self.id.clone())
|
||||
}
|
||||
|
||||
fn request_layout(
|
||||
&mut self,
|
||||
global_id: Option<&GlobalElementId>,
|
||||
cx: &mut WindowContext,
|
||||
) -> (LayoutId, Self::RequestLayoutState) {
|
||||
cx.with_element_state::<ClipboardState, _>(global_id.unwrap(), |state, cx| {
|
||||
let state = state.unwrap_or_default();
|
||||
|
||||
let content_element = self
|
||||
.content_builder
|
||||
.as_ref()
|
||||
.map(|builder| builder(cx).into_any_element());
|
||||
let value = self.value.clone();
|
||||
let clipboard_id = self.id.clone();
|
||||
let copied_callback = self.copied_callback.as_ref().map(|c| c.clone());
|
||||
let copied = state.copied.clone();
|
||||
let copide_value = *copied.borrow();
|
||||
|
||||
let mut element = h_flex()
|
||||
.gap_1()
|
||||
.items_center()
|
||||
.when_some(content_element, |this, element| this.child(element))
|
||||
.child(
|
||||
Button::new(clipboard_id)
|
||||
.icon(if copide_value {
|
||||
IconName::Check
|
||||
} else {
|
||||
IconName::Copy
|
||||
})
|
||||
.ghost()
|
||||
.xsmall()
|
||||
.when(!copide_value, |this| {
|
||||
this.on_click(move |_, cx| {
|
||||
cx.stop_propagation();
|
||||
cx.write_to_clipboard(ClipboardItem::new_string(value.to_string()));
|
||||
*copied.borrow_mut() = true;
|
||||
|
||||
let copied = copied.clone();
|
||||
cx.spawn(|cx| async move {
|
||||
cx.background_executor().timer(Duration::from_secs(2)).await;
|
||||
|
||||
*copied.borrow_mut() = false;
|
||||
})
|
||||
.detach();
|
||||
|
||||
if let Some(callback) = &copied_callback {
|
||||
callback(value.clone(), cx);
|
||||
}
|
||||
})
|
||||
}),
|
||||
)
|
||||
.into_any_element();
|
||||
|
||||
((element.request_layout(cx), element), state)
|
||||
})
|
||||
}
|
||||
|
||||
fn prepaint(
|
||||
&mut self,
|
||||
_: Option<&gpui::GlobalElementId>,
|
||||
_: gpui::Bounds<gpui::Pixels>,
|
||||
element: &mut Self::RequestLayoutState,
|
||||
cx: &mut WindowContext,
|
||||
) {
|
||||
element.prepaint(cx);
|
||||
}
|
||||
|
||||
fn paint(
|
||||
&mut self,
|
||||
_: Option<&gpui::GlobalElementId>,
|
||||
_: gpui::Bounds<gpui::Pixels>,
|
||||
element: &mut Self::RequestLayoutState,
|
||||
_: &mut Self::PrepaintState,
|
||||
cx: &mut WindowContext,
|
||||
) {
|
||||
element.paint(cx)
|
||||
}
|
||||
}
|
||||
377
crates/ui/src/color_picker.rs
Normal file
377
crates/ui/src/color_picker.rs
Normal file
@@ -0,0 +1,377 @@
|
||||
use gpui::{
|
||||
anchored, canvas, deferred, div, prelude::FluentBuilder as _, px, relative, AnchorCorner,
|
||||
AppContext, Bounds, ElementId, EventEmitter, FocusHandle, FocusableView, Hsla,
|
||||
InteractiveElement as _, IntoElement, KeyBinding, MouseButton, ParentElement, Pixels, Point,
|
||||
Render, SharedString, StatefulInteractiveElement as _, Styled, View, ViewContext,
|
||||
VisualContext,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
divider::Divider,
|
||||
h_flex,
|
||||
input::{InputEvent, TextInput},
|
||||
popover::Escape,
|
||||
theme::{ActiveTheme as _, Colorize},
|
||||
tooltip::Tooltip,
|
||||
v_flex, ColorExt as _, Sizable, Size, StyleSized,
|
||||
};
|
||||
|
||||
const KEY_CONTEXT: &str = "ColorPicker";
|
||||
|
||||
pub fn init(cx: &mut AppContext) {
|
||||
cx.bind_keys([KeyBinding::new("escape", Escape, Some(KEY_CONTEXT))])
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub enum ColorPickerEvent {
|
||||
Change(Option<Hsla>),
|
||||
}
|
||||
|
||||
fn color_palettes() -> Vec<Vec<Hsla>> {
|
||||
use crate::colors::DEFAULT_COLOR;
|
||||
use itertools::Itertools as _;
|
||||
|
||||
macro_rules! c {
|
||||
($color:tt) => {
|
||||
DEFAULT_COLOR
|
||||
.$color
|
||||
.keys()
|
||||
.sorted()
|
||||
.map(|k| DEFAULT_COLOR.$color.get(k).map(|c| c.hsla).unwrap())
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
}
|
||||
|
||||
vec![
|
||||
c!(stone),
|
||||
c!(red),
|
||||
c!(orange),
|
||||
c!(yellow),
|
||||
c!(green),
|
||||
c!(cyan),
|
||||
c!(blue),
|
||||
c!(purple),
|
||||
c!(pink),
|
||||
]
|
||||
}
|
||||
|
||||
pub struct ColorPicker {
|
||||
id: ElementId,
|
||||
focus_handle: FocusHandle,
|
||||
value: Option<Hsla>,
|
||||
featured_colors: Vec<Hsla>,
|
||||
hovered_color: Option<Hsla>,
|
||||
label: Option<SharedString>,
|
||||
size: Size,
|
||||
anchor: AnchorCorner,
|
||||
color_input: View<TextInput>,
|
||||
|
||||
open: bool,
|
||||
bounds: Bounds<Pixels>,
|
||||
}
|
||||
|
||||
impl ColorPicker {
|
||||
pub fn new(id: impl Into<ElementId>, cx: &mut ViewContext<Self>) -> Self {
|
||||
let color_input = cx.new_view(|cx| TextInput::new(cx).xsmall());
|
||||
|
||||
cx.subscribe(&color_input, |this, _, ev: &InputEvent, cx| match ev {
|
||||
InputEvent::Change(value) => {
|
||||
if let Ok(color) = Hsla::parse_hex_string(value) {
|
||||
this.value = Some(color);
|
||||
this.hovered_color = Some(color);
|
||||
}
|
||||
}
|
||||
InputEvent::PressEnter => {
|
||||
let val = this.color_input.read(cx).text();
|
||||
if let Ok(color) = Hsla::parse_hex_string(&val) {
|
||||
this.open = false;
|
||||
this.update_value(Some(color), true, cx);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
})
|
||||
.detach();
|
||||
|
||||
Self {
|
||||
id: id.into(),
|
||||
focus_handle: cx.focus_handle(),
|
||||
featured_colors: vec![
|
||||
crate::black(),
|
||||
crate::gray_600(),
|
||||
crate::gray_400(),
|
||||
crate::white(),
|
||||
crate::red_600(),
|
||||
crate::orange_600(),
|
||||
crate::yellow_600(),
|
||||
crate::green_600(),
|
||||
crate::blue_600(),
|
||||
crate::indigo_600(),
|
||||
crate::purple_600(),
|
||||
],
|
||||
value: None,
|
||||
hovered_color: None,
|
||||
size: Size::Medium,
|
||||
label: None,
|
||||
anchor: AnchorCorner::TopLeft,
|
||||
color_input,
|
||||
open: false,
|
||||
bounds: Bounds::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the featured colors to be displayed in the color picker.
|
||||
///
|
||||
/// This is used to display a set of colors that the user can quickly select from,
|
||||
/// for example provided user's last used colors.
|
||||
pub fn featured_colors(mut self, colors: Vec<Hsla>) -> Self {
|
||||
self.featured_colors = colors;
|
||||
self
|
||||
}
|
||||
|
||||
/// Set current color value.
|
||||
pub fn set_value(&mut self, value: Hsla, cx: &mut ViewContext<Self>) {
|
||||
self.update_value(Some(value), false, cx)
|
||||
}
|
||||
|
||||
/// Set the size of the color picker, default is `Size::Medium`.
|
||||
pub fn size(mut self, size: Size) -> Self {
|
||||
self.size = size;
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the label to be displayed above the color picker.
|
||||
///
|
||||
/// Default is `None`.
|
||||
pub fn label(mut self, label: impl Into<SharedString>) -> Self {
|
||||
self.label = Some(label.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the anchor corner of the color picker.
|
||||
///
|
||||
/// Default is `AnchorCorner::TopLeft`.
|
||||
pub fn anchor(mut self, anchor: AnchorCorner) -> Self {
|
||||
self.anchor = anchor;
|
||||
self
|
||||
}
|
||||
|
||||
fn on_escape(&mut self, _: &Escape, cx: &mut ViewContext<Self>) {
|
||||
cx.propagate();
|
||||
|
||||
self.open = false;
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn toggle_picker(&mut self, _: &gpui::ClickEvent, cx: &mut ViewContext<Self>) {
|
||||
self.open = !self.open;
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn update_value(&mut self, value: Option<Hsla>, emit: bool, cx: &mut ViewContext<Self>) {
|
||||
self.value = value;
|
||||
self.hovered_color = value;
|
||||
self.color_input.update(cx, |view, cx| {
|
||||
if let Some(value) = value {
|
||||
view.set_text(value.to_hex_string(), cx);
|
||||
} else {
|
||||
view.set_text("", cx);
|
||||
}
|
||||
});
|
||||
if emit {
|
||||
cx.emit(ColorPickerEvent::Change(value));
|
||||
}
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn render_item(
|
||||
&self,
|
||||
color: Hsla,
|
||||
clickable: bool,
|
||||
cx: &mut ViewContext<Self>,
|
||||
) -> impl IntoElement {
|
||||
div()
|
||||
.id(SharedString::from(format!(
|
||||
"color-{}",
|
||||
color.to_hex_string()
|
||||
)))
|
||||
.h_5()
|
||||
.w_5()
|
||||
.bg(color)
|
||||
.border_1()
|
||||
.border_color(color.darken(0.1))
|
||||
.when(clickable, |this| {
|
||||
this.cursor_pointer()
|
||||
.hover(|this| {
|
||||
this.border_color(color.darken(0.3))
|
||||
.bg(color.lighten(0.1))
|
||||
.shadow_sm()
|
||||
})
|
||||
.active(|this| this.border_color(color.darken(0.5)).bg(color.darken(0.2)))
|
||||
.on_mouse_move(cx.listener(move |view, _, cx| {
|
||||
view.hovered_color = Some(color);
|
||||
cx.notify();
|
||||
}))
|
||||
.on_click(cx.listener(move |view, _, cx| {
|
||||
view.update_value(Some(color), true, cx);
|
||||
view.open = false;
|
||||
cx.notify();
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
||||
fn render_colors(&self, cx: &mut ViewContext<Self>) -> impl IntoElement {
|
||||
v_flex()
|
||||
.gap_3()
|
||||
.child(
|
||||
h_flex().gap_1().children(
|
||||
self.featured_colors
|
||||
.iter()
|
||||
.map(|color| self.render_item(*color, true, cx)),
|
||||
),
|
||||
)
|
||||
.child(Divider::horizontal())
|
||||
.child(
|
||||
v_flex()
|
||||
.gap_1()
|
||||
.children(color_palettes().iter().map(|sub_colors| {
|
||||
h_flex().gap_1().children(
|
||||
sub_colors
|
||||
.iter()
|
||||
.rev()
|
||||
.map(|color| self.render_item(*color, true, cx)),
|
||||
)
|
||||
})),
|
||||
)
|
||||
.when_some(self.hovered_color, |this, hovered_color| {
|
||||
this.child(Divider::horizontal()).child(
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.items_center()
|
||||
.child(
|
||||
div()
|
||||
.bg(hovered_color)
|
||||
.flex_shrink_0()
|
||||
.border_1()
|
||||
.border_color(hovered_color.darken(0.2))
|
||||
.size_5()
|
||||
.rounded(px(cx.theme().radius)),
|
||||
)
|
||||
.child(self.color_input.clone()),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn resolved_corner(&self, bounds: Bounds<Pixels>) -> Point<Pixels> {
|
||||
match self.anchor {
|
||||
AnchorCorner::TopLeft => AnchorCorner::BottomLeft,
|
||||
AnchorCorner::TopRight => AnchorCorner::BottomRight,
|
||||
AnchorCorner::BottomLeft => AnchorCorner::TopLeft,
|
||||
AnchorCorner::BottomRight => AnchorCorner::TopRight,
|
||||
}
|
||||
.corner(bounds)
|
||||
}
|
||||
}
|
||||
|
||||
impl Sizable for ColorPicker {
|
||||
fn with_size(mut self, size: impl Into<Size>) -> Self {
|
||||
self.size = size.into();
|
||||
self
|
||||
}
|
||||
}
|
||||
impl EventEmitter<ColorPickerEvent> for ColorPicker {}
|
||||
impl FocusableView for ColorPicker {
|
||||
fn focus_handle(&self, _: &AppContext) -> FocusHandle {
|
||||
self.focus_handle.clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for ColorPicker {
|
||||
fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
|
||||
let display_title: SharedString = if let Some(value) = self.value {
|
||||
value.to_hex_string()
|
||||
} else {
|
||||
"".to_string()
|
||||
}
|
||||
.into();
|
||||
|
||||
let view = cx.view().clone();
|
||||
|
||||
div()
|
||||
.id(self.id.clone())
|
||||
.key_context(KEY_CONTEXT)
|
||||
.track_focus(&self.focus_handle)
|
||||
.on_action(cx.listener(Self::on_escape))
|
||||
.child(
|
||||
h_flex()
|
||||
.id("color-picker-input")
|
||||
.cursor_pointer()
|
||||
.gap_2()
|
||||
.items_center()
|
||||
.input_text_size(self.size)
|
||||
.line_height(relative(1.))
|
||||
.child(
|
||||
div()
|
||||
.id("color-picker-square")
|
||||
.bg(cx.theme().background)
|
||||
.border_1()
|
||||
.border_color(cx.theme().input)
|
||||
.rounded(px(cx.theme().radius))
|
||||
.bg(cx.theme().background)
|
||||
.shadow_sm()
|
||||
.overflow_hidden()
|
||||
.size_with(self.size)
|
||||
.when_some(self.value, |this, value| {
|
||||
this.bg(value).border_color(value.darken(0.3))
|
||||
})
|
||||
.tooltip(move |cx| Tooltip::new(display_title.clone(), cx)),
|
||||
)
|
||||
.when_some(self.label.clone(), |this, label| this.child(label))
|
||||
.on_click(cx.listener(Self::toggle_picker))
|
||||
.child(
|
||||
canvas(
|
||||
move |bounds, cx| view.update(cx, |r, _| r.bounds = bounds),
|
||||
|_, _, _| {},
|
||||
)
|
||||
.absolute()
|
||||
.size_full(),
|
||||
),
|
||||
)
|
||||
.when(self.open, |this| {
|
||||
this.child(
|
||||
deferred(
|
||||
anchored()
|
||||
.anchor(self.anchor)
|
||||
.snap_to_window_with_margin(px(8.))
|
||||
.position(self.resolved_corner(self.bounds))
|
||||
.child(
|
||||
div()
|
||||
.occlude()
|
||||
.map(|this| match self.anchor {
|
||||
AnchorCorner::TopLeft | AnchorCorner::TopRight => {
|
||||
this.mt_1p5()
|
||||
}
|
||||
AnchorCorner::BottomLeft | AnchorCorner::BottomRight => {
|
||||
this.mb_1p5()
|
||||
}
|
||||
})
|
||||
.w_72()
|
||||
.overflow_hidden()
|
||||
.rounded_lg()
|
||||
.p_3()
|
||||
.border_1()
|
||||
.border_color(cx.theme().border)
|
||||
.shadow_lg()
|
||||
.rounded_lg()
|
||||
.bg(cx.theme().background)
|
||||
.on_mouse_up_out(
|
||||
MouseButton::Left,
|
||||
cx.listener(|view, _, cx| view.on_escape(&Escape, cx)),
|
||||
)
|
||||
.child(self.render_colors(cx)),
|
||||
),
|
||||
)
|
||||
.with_priority(1),
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
297
crates/ui/src/colors.rs
Normal file
297
crates/ui/src/colors.rs
Normal file
@@ -0,0 +1,297 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use gpui::Hsla;
|
||||
use serde::{de::Error, Deserialize, Deserializer};
|
||||
|
||||
use crate::theme::hsl;
|
||||
use anyhow::Result;
|
||||
|
||||
pub(crate) trait ColorExt {
|
||||
fn to_hex_string(&self) -> String;
|
||||
fn parse_hex_string(hex: &str) -> Result<Hsla>;
|
||||
}
|
||||
|
||||
impl ColorExt for Hsla {
|
||||
fn to_hex_string(&self) -> String {
|
||||
let rgb = self.to_rgb();
|
||||
|
||||
if rgb.a < 1. {
|
||||
return format!(
|
||||
"#{:02X}{:02X}{:02X}{:02X}",
|
||||
((rgb.r * 255.) as u32),
|
||||
((rgb.g * 255.) as u32),
|
||||
((rgb.b * 255.) as u32),
|
||||
((self.a * 255.) as u32)
|
||||
);
|
||||
}
|
||||
|
||||
format!(
|
||||
"#{:02X}{:02X}{:02X}",
|
||||
((rgb.r * 255.) as u32),
|
||||
((rgb.g * 255.) as u32),
|
||||
((rgb.b * 255.) as u32)
|
||||
)
|
||||
}
|
||||
|
||||
fn parse_hex_string(hex: &str) -> Result<Hsla> {
|
||||
let hex = hex.trim_start_matches('#');
|
||||
let len = hex.len();
|
||||
if len != 6 && len != 8 {
|
||||
return Err(anyhow::anyhow!("invalid hex color"));
|
||||
}
|
||||
|
||||
let r = u8::from_str_radix(&hex[0..2], 16)? as f32 / 255.;
|
||||
let g = u8::from_str_radix(&hex[2..4], 16)? as f32 / 255.;
|
||||
let b = u8::from_str_radix(&hex[4..6], 16)? as f32 / 255.;
|
||||
let a = if len == 8 {
|
||||
u8::from_str_radix(&hex[6..8], 16)? as f32 / 255.
|
||||
} else {
|
||||
1.
|
||||
};
|
||||
|
||||
let v = gpui::Rgba { r, g, b, a };
|
||||
let color: Hsla = v.into();
|
||||
Ok(color)
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) static DEFAULT_COLOR: once_cell::sync::Lazy<ShadcnColors> =
|
||||
once_cell::sync::Lazy::new(|| {
|
||||
serde_json::from_str(include_str!("../colors.json")).expect("failed to parse default-json")
|
||||
});
|
||||
|
||||
type ColorScales = HashMap<usize, ShadcnColor>;
|
||||
|
||||
mod color_scales {
|
||||
use std::collections::HashMap;
|
||||
|
||||
use super::{ColorScales, ShadcnColor};
|
||||
|
||||
use serde::de::{Deserialize, Deserializer};
|
||||
|
||||
pub fn deserialize<'de, D>(deserializer: D) -> Result<ColorScales, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
let mut map = HashMap::new();
|
||||
for color in Vec::<ShadcnColor>::deserialize(deserializer)? {
|
||||
map.insert(color.scale, color);
|
||||
}
|
||||
Ok(map)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize)]
|
||||
pub(crate) struct ShadcnColors {
|
||||
pub(crate) black: ShadcnColor,
|
||||
pub(crate) white: ShadcnColor,
|
||||
#[serde(with = "color_scales")]
|
||||
pub(crate) slate: ColorScales,
|
||||
#[serde(with = "color_scales")]
|
||||
pub(crate) gray: ColorScales,
|
||||
#[serde(with = "color_scales")]
|
||||
pub(crate) zinc: ColorScales,
|
||||
#[serde(with = "color_scales")]
|
||||
pub(crate) neutral: ColorScales,
|
||||
#[serde(with = "color_scales")]
|
||||
pub(crate) stone: ColorScales,
|
||||
#[serde(with = "color_scales")]
|
||||
pub(crate) red: ColorScales,
|
||||
#[serde(with = "color_scales")]
|
||||
pub(crate) orange: ColorScales,
|
||||
#[serde(with = "color_scales")]
|
||||
pub(crate) amber: ColorScales,
|
||||
#[serde(with = "color_scales")]
|
||||
pub(crate) yellow: ColorScales,
|
||||
#[serde(with = "color_scales")]
|
||||
pub(crate) lime: ColorScales,
|
||||
#[serde(with = "color_scales")]
|
||||
pub(crate) green: ColorScales,
|
||||
#[serde(with = "color_scales")]
|
||||
pub(crate) emerald: ColorScales,
|
||||
#[serde(with = "color_scales")]
|
||||
pub(crate) teal: ColorScales,
|
||||
#[serde(with = "color_scales")]
|
||||
pub(crate) cyan: ColorScales,
|
||||
#[serde(with = "color_scales")]
|
||||
pub(crate) sky: ColorScales,
|
||||
#[serde(with = "color_scales")]
|
||||
pub(crate) blue: ColorScales,
|
||||
#[serde(with = "color_scales")]
|
||||
pub(crate) indigo: ColorScales,
|
||||
#[serde(with = "color_scales")]
|
||||
pub(crate) violet: ColorScales,
|
||||
#[serde(with = "color_scales")]
|
||||
pub(crate) purple: ColorScales,
|
||||
#[serde(with = "color_scales")]
|
||||
pub(crate) fuchsia: ColorScales,
|
||||
#[serde(with = "color_scales")]
|
||||
pub(crate) pink: ColorScales,
|
||||
#[serde(with = "color_scales")]
|
||||
pub(crate) rose: ColorScales,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Deserialize)]
|
||||
pub(crate) struct ShadcnColor {
|
||||
#[serde(default)]
|
||||
pub(crate) scale: usize,
|
||||
#[serde(deserialize_with = "from_hsl_channel", alias = "hslChannel")]
|
||||
pub(crate) hsla: Hsla,
|
||||
}
|
||||
|
||||
/// Deserialize Hsla from a string in the format "210 40% 98%"
|
||||
fn from_hsl_channel<'de, D>(deserializer: D) -> Result<Hsla, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
let s: String = Deserialize::deserialize(deserializer).unwrap();
|
||||
|
||||
let mut parts = s.split_whitespace();
|
||||
if parts.clone().count() != 3 {
|
||||
return Err(D::Error::custom(
|
||||
"expected hslChannel has 3 parts, e.g: '210 40% 98%'",
|
||||
));
|
||||
}
|
||||
|
||||
fn parse_number(s: &str) -> f32 {
|
||||
s.trim_end_matches('%')
|
||||
.parse()
|
||||
.expect("failed to parse number")
|
||||
}
|
||||
|
||||
let (h, s, l) = (
|
||||
parse_number(parts.next().unwrap()),
|
||||
parse_number(parts.next().unwrap()),
|
||||
parse_number(parts.next().unwrap()),
|
||||
);
|
||||
|
||||
Ok(hsl(h, s, l))
|
||||
}
|
||||
|
||||
macro_rules! color_method {
|
||||
($color:tt, $scale:tt) => {
|
||||
paste::paste! {
|
||||
#[inline]
|
||||
#[allow(unused)]
|
||||
pub fn [<$color _ $scale>]() -> Hsla {
|
||||
if let Some(color) = DEFAULT_COLOR.$color.get(&($scale as usize)) {
|
||||
return color.hsla;
|
||||
}
|
||||
|
||||
black()
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
macro_rules! color_methods {
|
||||
($color:tt) => {
|
||||
paste::paste! {
|
||||
/// Get color by scale number.
|
||||
///
|
||||
/// The possible scale numbers are:
|
||||
/// 50, 100, 200, 300, 400, 500, 600, 700, 800, 900, 950
|
||||
///
|
||||
/// If the scale number is not found, it will return black color.
|
||||
#[inline]
|
||||
pub fn [<$color>](scale: usize) -> Hsla {
|
||||
if let Some(color) = DEFAULT_COLOR.$color.get(&scale) {
|
||||
return color.hsla;
|
||||
}
|
||||
|
||||
black()
|
||||
}
|
||||
}
|
||||
|
||||
color_method!($color, 50);
|
||||
color_method!($color, 100);
|
||||
color_method!($color, 200);
|
||||
color_method!($color, 300);
|
||||
color_method!($color, 400);
|
||||
color_method!($color, 500);
|
||||
color_method!($color, 600);
|
||||
color_method!($color, 700);
|
||||
color_method!($color, 800);
|
||||
color_method!($color, 900);
|
||||
color_method!($color, 950);
|
||||
};
|
||||
}
|
||||
|
||||
pub fn black() -> Hsla {
|
||||
DEFAULT_COLOR.black.hsla
|
||||
}
|
||||
|
||||
pub fn white() -> Hsla {
|
||||
DEFAULT_COLOR.white.hsla
|
||||
}
|
||||
|
||||
color_methods!(slate);
|
||||
color_methods!(gray);
|
||||
color_methods!(zinc);
|
||||
color_methods!(neutral);
|
||||
color_methods!(stone);
|
||||
color_methods!(red);
|
||||
color_methods!(orange);
|
||||
color_methods!(amber);
|
||||
color_methods!(yellow);
|
||||
color_methods!(lime);
|
||||
color_methods!(green);
|
||||
color_methods!(emerald);
|
||||
color_methods!(teal);
|
||||
color_methods!(cyan);
|
||||
color_methods!(sky);
|
||||
color_methods!(blue);
|
||||
color_methods!(indigo);
|
||||
color_methods!(violet);
|
||||
color_methods!(purple);
|
||||
color_methods!(fuchsia);
|
||||
color_methods!(pink);
|
||||
color_methods!(rose);
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use gpui::{rgb, rgba};
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_default_colors() {
|
||||
assert_eq!(white(), hsl(0.0, 0.0, 100.0));
|
||||
assert_eq!(black(), hsl(0.0, 0.0, 0.0));
|
||||
|
||||
assert_eq!(slate_50(), hsl(210.0, 40.0, 98.0));
|
||||
assert_eq!(slate_100(), hsl(210.0, 40.0, 96.1));
|
||||
assert_eq!(slate_900(), hsl(222.2, 47.4, 11.2));
|
||||
|
||||
assert_eq!(red_50(), hsl(0.0, 85.7, 97.3));
|
||||
assert_eq!(yellow_100(), hsl(54.9, 96.7, 88.0));
|
||||
assert_eq!(green_200(), hsl(141.0, 78.9, 85.1));
|
||||
assert_eq!(cyan_300(), hsl(187.0, 92.4, 69.0));
|
||||
assert_eq!(blue_400(), hsl(213.1, 93.9, 67.8));
|
||||
assert_eq!(indigo_500(), hsl(238.7, 83.5, 66.7));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_to_hex_string() {
|
||||
let color: Hsla = rgb(0xf8fafc).into();
|
||||
assert_eq!(color.to_hex_string(), "#F8FAFC");
|
||||
|
||||
let color: Hsla = rgb(0xfef2f2).into();
|
||||
assert_eq!(color.to_hex_string(), "#FEF2F2");
|
||||
|
||||
let color: Hsla = rgba(0x0413fcaa).into();
|
||||
assert_eq!(color.to_hex_string(), "#0413FCAA");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_from_hex_string() {
|
||||
let color: Hsla = Hsla::parse_hex_string("#F8FAFC").unwrap();
|
||||
assert_eq!(color, rgb(0xf8fafc).into());
|
||||
|
||||
let color: Hsla = Hsla::parse_hex_string("#FEF2F2").unwrap();
|
||||
assert_eq!(color, rgb(0xfef2f2).into());
|
||||
|
||||
let color: Hsla = Hsla::parse_hex_string("#0413FCAA").unwrap();
|
||||
assert_eq!(color, rgba(0x0413fcaa).into());
|
||||
}
|
||||
}
|
||||
230
crates/ui/src/context_menu.rs
Normal file
230
crates/ui/src/context_menu.rs
Normal file
@@ -0,0 +1,230 @@
|
||||
use std::{cell::RefCell, rc::Rc};
|
||||
|
||||
use gpui::{
|
||||
anchored, deferred, div, prelude::FluentBuilder, px, relative, AnchorCorner, AnyElement,
|
||||
DismissEvent, DispatchPhase, Element, ElementId, Focusable, GlobalElementId,
|
||||
InteractiveElement, IntoElement, MouseButton, MouseDownEvent, ParentElement, Pixels, Point,
|
||||
Position, Stateful, Style, View, ViewContext, WindowContext,
|
||||
};
|
||||
|
||||
use crate::popup_menu::PopupMenu;
|
||||
|
||||
pub trait ContextMenuExt: ParentElement + Sized {
|
||||
fn context_menu(
|
||||
self,
|
||||
f: impl Fn(PopupMenu, &mut ViewContext<PopupMenu>) -> PopupMenu + 'static,
|
||||
) -> Self {
|
||||
self.child(ContextMenu::new("context-menu").menu(f))
|
||||
}
|
||||
}
|
||||
|
||||
impl<E> ContextMenuExt for Stateful<E> where E: ParentElement {}
|
||||
impl<E> ContextMenuExt for Focusable<E> where E: ParentElement {}
|
||||
|
||||
/// A context menu that can be shown on right-click.
|
||||
pub struct ContextMenu {
|
||||
id: ElementId,
|
||||
menu: Option<Box<dyn Fn(PopupMenu, &mut ViewContext<PopupMenu>) -> PopupMenu + 'static>>,
|
||||
anchor: AnchorCorner,
|
||||
}
|
||||
|
||||
impl ContextMenu {
|
||||
pub fn new(id: impl Into<ElementId>) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
menu: None,
|
||||
anchor: AnchorCorner::TopLeft,
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn menu<F>(mut self, builder: F) -> Self
|
||||
where
|
||||
F: Fn(PopupMenu, &mut ViewContext<PopupMenu>) -> PopupMenu + 'static,
|
||||
{
|
||||
self.menu = Some(Box::new(builder));
|
||||
self
|
||||
}
|
||||
|
||||
fn with_element_state<R>(
|
||||
&mut self,
|
||||
id: &GlobalElementId,
|
||||
cx: &mut WindowContext,
|
||||
f: impl FnOnce(&mut Self, &mut ContextMenuState, &mut WindowContext) -> R,
|
||||
) -> R {
|
||||
cx.with_optional_element_state::<ContextMenuState, _>(Some(id), |element_state, cx| {
|
||||
let mut element_state = element_state.unwrap().unwrap_or_default();
|
||||
let result = f(self, &mut element_state, cx);
|
||||
(result, Some(element_state))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoElement for ContextMenu {
|
||||
type Element = Self;
|
||||
|
||||
fn into_element(self) -> Self::Element {
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
pub struct ContextMenuState {
|
||||
menu_view: Rc<RefCell<Option<View<PopupMenu>>>>,
|
||||
menu_element: Option<AnyElement>,
|
||||
open: Rc<RefCell<bool>>,
|
||||
position: Rc<RefCell<Point<Pixels>>>,
|
||||
}
|
||||
|
||||
impl Default for ContextMenuState {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
menu_view: Rc::new(RefCell::new(None)),
|
||||
menu_element: None,
|
||||
open: Rc::new(RefCell::new(false)),
|
||||
position: Default::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Element for ContextMenu {
|
||||
type RequestLayoutState = ContextMenuState;
|
||||
type PrepaintState = ();
|
||||
|
||||
fn id(&self) -> Option<ElementId> {
|
||||
Some(self.id.clone())
|
||||
}
|
||||
|
||||
fn request_layout(
|
||||
&mut self,
|
||||
id: Option<&gpui::GlobalElementId>,
|
||||
cx: &mut WindowContext,
|
||||
) -> (gpui::LayoutId, Self::RequestLayoutState) {
|
||||
let mut style = Style::default();
|
||||
// Set the layout style relative to the table view to get same size.
|
||||
style.position = Position::Absolute;
|
||||
style.flex_grow = 1.0;
|
||||
style.flex_shrink = 1.0;
|
||||
style.size.width = relative(1.).into();
|
||||
style.size.height = relative(1.).into();
|
||||
|
||||
let anchor = self.anchor;
|
||||
|
||||
self.with_element_state(id.unwrap(), cx, |_, state: &mut ContextMenuState, cx| {
|
||||
let position = state.position.clone();
|
||||
let position = position.borrow();
|
||||
let open = state.open.clone();
|
||||
let menu_view = state.menu_view.borrow().clone();
|
||||
|
||||
let (menu_element, menu_layout_id) = if *open.borrow() {
|
||||
let has_menu_item = menu_view
|
||||
.as_ref()
|
||||
.map(|menu| !menu.read(cx).is_empty())
|
||||
.unwrap_or(false);
|
||||
|
||||
if has_menu_item {
|
||||
let mut menu_element = deferred(
|
||||
anchored()
|
||||
.position(*position)
|
||||
.snap_to_window_with_margin(px(8.))
|
||||
.anchor(anchor)
|
||||
.when_some(menu_view, |this, menu| {
|
||||
// Focus the menu, so that can be handle the action.
|
||||
menu.focus_handle(cx).focus(cx);
|
||||
|
||||
this.child(div().occlude().child(menu.clone()))
|
||||
}),
|
||||
)
|
||||
.with_priority(1)
|
||||
.into_any();
|
||||
|
||||
let menu_layout_id = menu_element.request_layout(cx);
|
||||
(Some(menu_element), Some(menu_layout_id))
|
||||
} else {
|
||||
(None, None)
|
||||
}
|
||||
} else {
|
||||
(None, None)
|
||||
};
|
||||
|
||||
let mut layout_ids = vec![];
|
||||
if let Some(menu_layout_id) = menu_layout_id {
|
||||
layout_ids.push(menu_layout_id);
|
||||
}
|
||||
|
||||
let layout_id = cx.request_layout(style, layout_ids);
|
||||
|
||||
(
|
||||
layout_id,
|
||||
ContextMenuState {
|
||||
menu_element,
|
||||
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn prepaint(
|
||||
&mut self,
|
||||
_: Option<&gpui::GlobalElementId>,
|
||||
_: gpui::Bounds<gpui::Pixels>,
|
||||
request_layout: &mut Self::RequestLayoutState,
|
||||
cx: &mut WindowContext,
|
||||
) -> Self::PrepaintState {
|
||||
if let Some(menu_element) = &mut request_layout.menu_element {
|
||||
menu_element.prepaint(cx);
|
||||
}
|
||||
}
|
||||
|
||||
fn paint(
|
||||
&mut self,
|
||||
id: Option<&gpui::GlobalElementId>,
|
||||
bounds: gpui::Bounds<gpui::Pixels>,
|
||||
request_layout: &mut Self::RequestLayoutState,
|
||||
_: &mut Self::PrepaintState,
|
||||
cx: &mut WindowContext,
|
||||
) {
|
||||
if let Some(menu_element) = &mut request_layout.menu_element {
|
||||
menu_element.paint(cx);
|
||||
}
|
||||
|
||||
let Some(builder) = self.menu.take() else {
|
||||
return;
|
||||
};
|
||||
|
||||
self.with_element_state(
|
||||
id.unwrap(),
|
||||
cx,
|
||||
|_view, state: &mut ContextMenuState, cx| {
|
||||
let position = state.position.clone();
|
||||
let open = state.open.clone();
|
||||
let menu_view = state.menu_view.clone();
|
||||
|
||||
// When right mouse click, to build content menu, and show it at the mouse position.
|
||||
cx.on_mouse_event(move |event: &MouseDownEvent, phase, cx| {
|
||||
if phase == DispatchPhase::Bubble
|
||||
&& event.button == MouseButton::Right
|
||||
&& bounds.contains(&event.position)
|
||||
{
|
||||
*position.borrow_mut() = event.position;
|
||||
*open.borrow_mut() = true;
|
||||
|
||||
let menu =
|
||||
PopupMenu::build(cx, |menu, cx| (builder)(menu, cx)).into_element();
|
||||
|
||||
let open = open.clone();
|
||||
cx.subscribe(&menu, move |_, _: &DismissEvent, cx| {
|
||||
*open.borrow_mut() = false;
|
||||
cx.refresh();
|
||||
})
|
||||
.detach();
|
||||
|
||||
*menu_view.borrow_mut() = Some(menu);
|
||||
|
||||
cx.refresh();
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
84
crates/ui/src/divider.rs
Normal file
84
crates/ui/src/divider.rs
Normal file
@@ -0,0 +1,84 @@
|
||||
use gpui::{
|
||||
div, prelude::FluentBuilder as _, px, Axis, Div, Hsla, IntoElement, ParentElement, RenderOnce,
|
||||
SharedString, Styled,
|
||||
};
|
||||
|
||||
use crate::theme::ActiveTheme;
|
||||
|
||||
/// A divider that can be either vertical or horizontal.
|
||||
#[derive(IntoElement)]
|
||||
pub struct Divider {
|
||||
base: Div,
|
||||
label: Option<SharedString>,
|
||||
axis: Axis,
|
||||
color: Option<Hsla>,
|
||||
}
|
||||
|
||||
impl Divider {
|
||||
pub fn vertical() -> Self {
|
||||
Self {
|
||||
base: div().h_full(),
|
||||
axis: Axis::Vertical,
|
||||
label: None,
|
||||
color: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn horizontal() -> Self {
|
||||
Self {
|
||||
base: div().w_full(),
|
||||
axis: Axis::Horizontal,
|
||||
label: None,
|
||||
color: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn label(mut self, label: impl Into<SharedString>) -> Self {
|
||||
self.label = Some(label.into());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn color(mut self, color: impl Into<Hsla>) -> Self {
|
||||
self.color = Some(color.into());
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl Styled for Divider {
|
||||
fn style(&mut self) -> &mut gpui::StyleRefinement {
|
||||
self.base.style()
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for Divider {
|
||||
fn render(self, cx: &mut gpui::WindowContext) -> impl IntoElement {
|
||||
let theme = cx.theme();
|
||||
|
||||
self.base
|
||||
.flex()
|
||||
.flex_shrink_0()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.child(
|
||||
div()
|
||||
.absolute()
|
||||
.map(|this| match self.axis {
|
||||
Axis::Vertical => this.w(px(1.)).h_full(),
|
||||
Axis::Horizontal => this.h(px(1.)).w_full(),
|
||||
})
|
||||
.bg(self.color.unwrap_or(cx.theme().border)),
|
||||
)
|
||||
.when_some(self.label, |this, label| {
|
||||
this.child(
|
||||
div()
|
||||
.px_2()
|
||||
.py_1()
|
||||
.mx_auto()
|
||||
.text_xs()
|
||||
.bg(cx.theme().background)
|
||||
.text_color(theme.muted_foreground)
|
||||
.child(label),
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
443
crates/ui/src/dock/dock.rs
Normal file
443
crates/ui/src/dock/dock.rs
Normal file
@@ -0,0 +1,443 @@
|
||||
//! Dock is a fixed container that places at left, bottom, right of the Windows.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use gpui::{
|
||||
div, prelude::FluentBuilder as _, px, Axis, Element, Entity, InteractiveElement as _,
|
||||
IntoElement, MouseMoveEvent, MouseUpEvent, ParentElement as _, Pixels, Point, Render,
|
||||
StatefulInteractiveElement, Style, Styled as _, View, ViewContext, VisualContext as _,
|
||||
WeakView, WindowContext,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{
|
||||
resizable::{HANDLE_PADDING, HANDLE_SIZE, PANEL_MIN_SIZE},
|
||||
theme::ActiveTheme as _,
|
||||
AxisExt as _, StyledExt,
|
||||
};
|
||||
|
||||
use super::{DockArea, DockItem, PanelView, TabPanel};
|
||||
|
||||
#[derive(Clone, Render)]
|
||||
struct ResizePanel;
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub enum DockPlacement {
|
||||
#[serde(rename = "center")]
|
||||
Center,
|
||||
#[serde(rename = "left")]
|
||||
Left,
|
||||
#[serde(rename = "bottom")]
|
||||
Bottom,
|
||||
#[serde(rename = "right")]
|
||||
Right,
|
||||
}
|
||||
|
||||
impl DockPlacement {
|
||||
fn axis(&self) -> Axis {
|
||||
match self {
|
||||
Self::Left | Self::Right => Axis::Horizontal,
|
||||
Self::Bottom => Axis::Vertical,
|
||||
Self::Center => unreachable!(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_left(&self) -> bool {
|
||||
matches!(self, Self::Left)
|
||||
}
|
||||
|
||||
pub fn is_bottom(&self) -> bool {
|
||||
matches!(self, Self::Bottom)
|
||||
}
|
||||
|
||||
pub fn is_right(&self) -> bool {
|
||||
matches!(self, Self::Right)
|
||||
}
|
||||
}
|
||||
|
||||
/// The Dock is a fixed container that places at left, bottom, right of the Windows.
|
||||
///
|
||||
/// This is unlike Panel, it can't be move or add any other panel.
|
||||
pub struct Dock {
|
||||
pub(super) placement: DockPlacement,
|
||||
dock_area: WeakView<DockArea>,
|
||||
pub(crate) panel: DockItem,
|
||||
/// The size is means the width or height of the Dock, if the placement is left or right, the size is width, otherwise the size is height.
|
||||
pub(super) size: Pixels,
|
||||
pub(super) open: bool,
|
||||
/// Whether the Dock is collapsible, default: true
|
||||
pub(super) collapsible: bool,
|
||||
|
||||
// Runtime state
|
||||
/// Whether the Dock is resizing
|
||||
is_resizing: bool,
|
||||
}
|
||||
|
||||
impl Dock {
|
||||
pub(crate) fn new(
|
||||
dock_area: WeakView<DockArea>,
|
||||
placement: DockPlacement,
|
||||
cx: &mut ViewContext<Self>,
|
||||
) -> Self {
|
||||
let panel = cx.new_view(|cx| {
|
||||
let mut tab = TabPanel::new(None, dock_area.clone(), cx);
|
||||
tab.closeable = false;
|
||||
tab
|
||||
});
|
||||
|
||||
let panel = DockItem::Tabs {
|
||||
items: Vec::new(),
|
||||
active_ix: 0,
|
||||
view: panel.clone(),
|
||||
};
|
||||
|
||||
Self::subscribe_panel_events(dock_area.clone(), &panel, cx);
|
||||
|
||||
Self {
|
||||
placement,
|
||||
dock_area,
|
||||
panel,
|
||||
open: true,
|
||||
collapsible: true,
|
||||
size: px(200.0),
|
||||
is_resizing: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn left(dock_area: WeakView<DockArea>, cx: &mut ViewContext<Self>) -> Self {
|
||||
Self::new(dock_area, DockPlacement::Left, cx)
|
||||
}
|
||||
|
||||
pub fn bottom(dock_area: WeakView<DockArea>, cx: &mut ViewContext<Self>) -> Self {
|
||||
Self::new(dock_area, DockPlacement::Bottom, cx)
|
||||
}
|
||||
|
||||
pub fn right(dock_area: WeakView<DockArea>, cx: &mut ViewContext<Self>) -> Self {
|
||||
Self::new(dock_area, DockPlacement::Right, cx)
|
||||
}
|
||||
|
||||
/// Update the Dock to be collapsible or not.
|
||||
///
|
||||
/// And if the Dock is not collapsible, it will be open.
|
||||
pub fn set_collapsible(&mut self, collapsible: bool, cx: &mut ViewContext<Self>) {
|
||||
self.collapsible = collapsible;
|
||||
if !collapsible {
|
||||
self.open = true
|
||||
}
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub(super) fn from_state(
|
||||
dock_area: WeakView<DockArea>,
|
||||
placement: DockPlacement,
|
||||
size: Pixels,
|
||||
panel: DockItem,
|
||||
open: bool,
|
||||
cx: &mut WindowContext,
|
||||
) -> Self {
|
||||
Self::subscribe_panel_events(dock_area.clone(), &panel, cx);
|
||||
|
||||
if !open {
|
||||
if let DockItem::Tabs { view, .. } = panel.clone() {
|
||||
view.update(cx, |panel, cx| {
|
||||
panel.set_collapsed(true, cx);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Self {
|
||||
placement,
|
||||
dock_area,
|
||||
panel,
|
||||
open,
|
||||
size,
|
||||
collapsible: true,
|
||||
is_resizing: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn subscribe_panel_events(
|
||||
dock_area: WeakView<DockArea>,
|
||||
panel: &DockItem,
|
||||
cx: &mut WindowContext,
|
||||
) {
|
||||
match panel {
|
||||
DockItem::Tabs { view, .. } => {
|
||||
cx.defer({
|
||||
let view = view.clone();
|
||||
move |cx| {
|
||||
_ = dock_area.update(cx, |this, cx| {
|
||||
this.subscribe_panel(&view, cx);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
DockItem::Split { items, view, .. } => {
|
||||
for item in items {
|
||||
Self::subscribe_panel_events(dock_area.clone(), item, cx);
|
||||
}
|
||||
cx.defer({
|
||||
let view = view.clone();
|
||||
move |cx| {
|
||||
_ = dock_area.update(cx, |this, cx| {
|
||||
this.subscribe_panel(&view, cx);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
DockItem::Panel { .. } => {
|
||||
// Not supported
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_panel(&mut self, panel: DockItem, cx: &mut ViewContext<Self>) {
|
||||
self.panel = panel;
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub fn is_open(&self) -> bool {
|
||||
self.open
|
||||
}
|
||||
|
||||
pub fn toggle_open(&mut self, cx: &mut ViewContext<Self>) {
|
||||
self.set_open(!self.open, cx);
|
||||
}
|
||||
|
||||
/// Returns the size of the Dock, the size is means the width or height of
|
||||
/// the Dock, if the placement is left or right, the size is width,
|
||||
/// otherwise the size is height.
|
||||
pub fn size(&self) -> Pixels {
|
||||
self.size
|
||||
}
|
||||
|
||||
/// Set the size of the Dock.
|
||||
pub fn set_size(&mut self, size: Pixels, cx: &mut ViewContext<Self>) {
|
||||
self.size = size.max(PANEL_MIN_SIZE);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Set the open state of the Dock.
|
||||
pub fn set_open(&mut self, open: bool, cx: &mut ViewContext<Self>) {
|
||||
self.open = open;
|
||||
let item = self.panel.clone();
|
||||
cx.defer(move |_, cx| {
|
||||
item.set_collapsed(!open, cx);
|
||||
});
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Add item to the Dock.
|
||||
pub fn add_panel(&mut self, panel: Arc<dyn PanelView>, cx: &mut ViewContext<Self>) {
|
||||
self.panel.add_panel(panel, &self.dock_area, cx);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn render_resize_handle(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
|
||||
let axis = self.placement.axis();
|
||||
let neg_offset = -HANDLE_PADDING;
|
||||
let view = cx.view().clone();
|
||||
|
||||
div()
|
||||
.id("resize-handle")
|
||||
.occlude()
|
||||
.absolute()
|
||||
.flex_shrink_0()
|
||||
.when(self.placement.is_left(), |this| {
|
||||
// FIXME: Improve this to let the scroll bar have px(HANDLE_PADDING)
|
||||
this.cursor_col_resize()
|
||||
.top_0()
|
||||
.right(px(1.))
|
||||
.h_full()
|
||||
.w(HANDLE_SIZE)
|
||||
.pl(HANDLE_PADDING)
|
||||
})
|
||||
.when(self.placement.is_right(), |this| {
|
||||
this.cursor_col_resize()
|
||||
.top_0()
|
||||
.left(neg_offset)
|
||||
.h_full()
|
||||
.w(HANDLE_SIZE)
|
||||
.px(HANDLE_PADDING)
|
||||
})
|
||||
.when(self.placement.is_bottom(), |this| {
|
||||
this.cursor_row_resize()
|
||||
.top(neg_offset)
|
||||
.left_0()
|
||||
.w_full()
|
||||
.h(HANDLE_SIZE)
|
||||
.py(HANDLE_PADDING)
|
||||
})
|
||||
.child(
|
||||
div()
|
||||
.bg(cx.theme().border)
|
||||
.when(axis.is_horizontal(), |this| this.h_full().w(HANDLE_SIZE))
|
||||
.when(axis.is_vertical(), |this| this.w_full().h(HANDLE_SIZE)),
|
||||
)
|
||||
.on_drag(ResizePanel {}, move |info, _, cx| {
|
||||
cx.stop_propagation();
|
||||
view.update(cx, |view, _| {
|
||||
view.is_resizing = true;
|
||||
});
|
||||
cx.new_view(|_| info.clone())
|
||||
})
|
||||
}
|
||||
fn resize(&mut self, mouse_position: Point<Pixels>, cx: &mut ViewContext<Self>) {
|
||||
if !self.is_resizing {
|
||||
return;
|
||||
}
|
||||
|
||||
let dock_area = self
|
||||
.dock_area
|
||||
.upgrade()
|
||||
.expect("DockArea is missing")
|
||||
.read(cx);
|
||||
let area_bounds = dock_area.bounds;
|
||||
let mut left_dock_size = Pixels(0.0);
|
||||
let mut right_dock_size = Pixels(0.0);
|
||||
|
||||
// Get the size of the left dock if it's open and not the current dock
|
||||
if let Some(left_dock) = &dock_area.left_dock {
|
||||
if left_dock.entity_id() != cx.view().entity_id() {
|
||||
let left_dock_read = left_dock.read(cx);
|
||||
if left_dock_read.is_open() {
|
||||
left_dock_size = left_dock_read.size;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get the size of the right dock if it's open and not the current dock
|
||||
if let Some(right_dock) = &dock_area.right_dock {
|
||||
if right_dock.entity_id() != cx.view().entity_id() {
|
||||
let right_dock_read = right_dock.read(cx);
|
||||
if right_dock_read.is_open() {
|
||||
right_dock_size = right_dock_read.size;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let size = match self.placement {
|
||||
DockPlacement::Left => mouse_position.x - area_bounds.left(),
|
||||
DockPlacement::Right => area_bounds.right() - mouse_position.x,
|
||||
DockPlacement::Bottom => area_bounds.bottom() - mouse_position.y,
|
||||
DockPlacement::Center => unreachable!(),
|
||||
};
|
||||
match self.placement {
|
||||
DockPlacement::Left => {
|
||||
let max_size = area_bounds.size.width - PANEL_MIN_SIZE - right_dock_size;
|
||||
self.size = size.clamp(PANEL_MIN_SIZE, max_size);
|
||||
}
|
||||
DockPlacement::Right => {
|
||||
let max_size = area_bounds.size.width - PANEL_MIN_SIZE - left_dock_size;
|
||||
self.size = size.clamp(PANEL_MIN_SIZE, max_size);
|
||||
}
|
||||
DockPlacement::Bottom => {
|
||||
let max_size = area_bounds.size.height - PANEL_MIN_SIZE;
|
||||
self.size = size.clamp(PANEL_MIN_SIZE, max_size);
|
||||
}
|
||||
DockPlacement::Center => unreachable!(),
|
||||
}
|
||||
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn done_resizing(&mut self, _: &mut ViewContext<Self>) {
|
||||
self.is_resizing = false;
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for Dock {
|
||||
fn render(&mut self, cx: &mut ViewContext<Self>) -> impl gpui::IntoElement {
|
||||
if !self.open && !self.placement.is_bottom() {
|
||||
return div();
|
||||
}
|
||||
|
||||
div()
|
||||
.relative()
|
||||
.overflow_hidden()
|
||||
.map(|this| match self.placement {
|
||||
DockPlacement::Left | DockPlacement::Right => this.h_flex().h_full().w(self.size),
|
||||
DockPlacement::Bottom => this.w_full().h(self.size),
|
||||
DockPlacement::Center => unreachable!(),
|
||||
})
|
||||
// Bottom Dock should keep the title bar, then user can click the Toggle button
|
||||
.when(!self.open && self.placement.is_bottom(), |this| {
|
||||
this.h(px(29.))
|
||||
})
|
||||
.map(|this| match &self.panel {
|
||||
DockItem::Split { view, .. } => this.child(view.clone()),
|
||||
DockItem::Tabs { view, .. } => this.child(view.clone()),
|
||||
DockItem::Panel { view, .. } => this.child(view.clone().view()),
|
||||
})
|
||||
.child(self.render_resize_handle(cx))
|
||||
.child(DockElement {
|
||||
view: cx.view().clone(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
struct DockElement {
|
||||
view: View<Dock>,
|
||||
}
|
||||
|
||||
impl IntoElement for DockElement {
|
||||
type Element = Self;
|
||||
|
||||
fn into_element(self) -> Self::Element {
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl Element for DockElement {
|
||||
type RequestLayoutState = ();
|
||||
type PrepaintState = ();
|
||||
|
||||
fn id(&self) -> Option<gpui::ElementId> {
|
||||
None
|
||||
}
|
||||
|
||||
fn request_layout(
|
||||
&mut self,
|
||||
_: Option<&gpui::GlobalElementId>,
|
||||
cx: &mut gpui::WindowContext,
|
||||
) -> (gpui::LayoutId, Self::RequestLayoutState) {
|
||||
(cx.request_layout(Style::default(), None), ())
|
||||
}
|
||||
|
||||
fn prepaint(
|
||||
&mut self,
|
||||
_: Option<&gpui::GlobalElementId>,
|
||||
_: gpui::Bounds<Pixels>,
|
||||
_: &mut Self::RequestLayoutState,
|
||||
_: &mut gpui::WindowContext,
|
||||
) -> Self::PrepaintState {
|
||||
}
|
||||
|
||||
fn paint(
|
||||
&mut self,
|
||||
_: Option<&gpui::GlobalElementId>,
|
||||
_: gpui::Bounds<Pixels>,
|
||||
_: &mut Self::RequestLayoutState,
|
||||
_: &mut Self::PrepaintState,
|
||||
cx: &mut gpui::WindowContext,
|
||||
) {
|
||||
cx.on_mouse_event({
|
||||
let view = self.view.clone();
|
||||
move |e: &MouseMoveEvent, phase, cx| {
|
||||
if phase.bubble() {
|
||||
view.update(cx, |view, cx| view.resize(e.position, cx))
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// When any mouse up, stop dragging
|
||||
cx.on_mouse_event({
|
||||
let view = self.view.clone();
|
||||
move |_: &MouseUpEvent, phase, cx| {
|
||||
if phase.bubble() {
|
||||
view.update(cx, |view, cx| view.done_resizing(cx));
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
55
crates/ui/src/dock/invalid_panel.rs
Normal file
55
crates/ui/src/dock/invalid_panel.rs
Normal file
@@ -0,0 +1,55 @@
|
||||
use gpui::{
|
||||
AppContext, EventEmitter, FocusHandle, FocusableView, ParentElement as _, Render, SharedString,
|
||||
Styled as _, WindowContext,
|
||||
};
|
||||
|
||||
use crate::theme::ActiveTheme as _;
|
||||
|
||||
use super::{DockItemState, Panel, PanelEvent};
|
||||
|
||||
pub(crate) struct InvalidPanel {
|
||||
name: SharedString,
|
||||
focus_handle: FocusHandle,
|
||||
old_state: DockItemState,
|
||||
}
|
||||
|
||||
impl InvalidPanel {
|
||||
pub(crate) fn new(name: &str, state: DockItemState, cx: &mut WindowContext) -> Self {
|
||||
Self {
|
||||
focus_handle: cx.focus_handle(),
|
||||
name: SharedString::from(name.to_owned()),
|
||||
old_state: state,
|
||||
}
|
||||
}
|
||||
}
|
||||
impl Panel for InvalidPanel {
|
||||
fn panel_name(&self) -> &'static str {
|
||||
"InvalidPanel"
|
||||
}
|
||||
|
||||
fn dump(&self, _cx: &AppContext) -> super::DockItemState {
|
||||
self.old_state.clone()
|
||||
}
|
||||
}
|
||||
impl EventEmitter<PanelEvent> for InvalidPanel {}
|
||||
impl FocusableView for InvalidPanel {
|
||||
fn focus_handle(&self, _: &AppContext) -> FocusHandle {
|
||||
self.focus_handle.clone()
|
||||
}
|
||||
}
|
||||
impl Render for InvalidPanel {
|
||||
fn render(&mut self, cx: &mut gpui::ViewContext<Self>) -> impl gpui::IntoElement {
|
||||
gpui::div()
|
||||
.size_full()
|
||||
.my_6()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child(format!(
|
||||
"The `{}` panel type is not registered in PanelRegistry.",
|
||||
self.name.clone()
|
||||
))
|
||||
}
|
||||
}
|
||||
811
crates/ui/src/dock/mod.rs
Normal file
811
crates/ui/src/dock/mod.rs
Normal file
@@ -0,0 +1,811 @@
|
||||
mod dock;
|
||||
mod invalid_panel;
|
||||
mod panel;
|
||||
mod stack_panel;
|
||||
mod state;
|
||||
mod tab_panel;
|
||||
|
||||
use anyhow::Result;
|
||||
pub use dock::*;
|
||||
use gpui::{
|
||||
actions, canvas, div, prelude::FluentBuilder, AnyElement, AnyView, AppContext, Axis, Bounds,
|
||||
Edges, Entity as _, EntityId, EventEmitter, InteractiveElement as _, IntoElement,
|
||||
ParentElement as _, Pixels, Render, SharedString, Styled, Subscription, View, ViewContext,
|
||||
VisualContext, WeakView, WindowContext,
|
||||
};
|
||||
use std::sync::Arc;
|
||||
|
||||
pub use panel::*;
|
||||
pub use stack_panel::*;
|
||||
pub use state::*;
|
||||
pub use tab_panel::*;
|
||||
|
||||
pub fn init(cx: &mut AppContext) {
|
||||
cx.set_global(PanelRegistry::new());
|
||||
}
|
||||
|
||||
actions!(dock, [ToggleZoom, ClosePanel]);
|
||||
|
||||
pub enum DockEvent {
|
||||
/// The layout of the dock has changed, subscribers this to save the layout.
|
||||
///
|
||||
/// This event is emitted when every time the layout of the dock has changed,
|
||||
/// So it emits may be too frequently, you may want to debounce the event.
|
||||
LayoutChanged,
|
||||
}
|
||||
|
||||
/// The main area of the dock.
|
||||
pub struct DockArea {
|
||||
id: SharedString,
|
||||
/// The version is used to special the default layout, this is like the `panel_version` in [`Panel`](Panel).
|
||||
version: Option<usize>,
|
||||
pub(crate) bounds: Bounds<Pixels>,
|
||||
|
||||
/// The center view of the dockarea.
|
||||
items: DockItem,
|
||||
|
||||
/// The entity_id of the [`TabPanel`](TabPanel) where each toggle button should be displayed,
|
||||
toggle_button_panels: Edges<Option<EntityId>>,
|
||||
|
||||
/// The left dock of the dock_area.
|
||||
left_dock: Option<View<Dock>>,
|
||||
/// The bottom dock of the dock_area.
|
||||
bottom_dock: Option<View<Dock>>,
|
||||
/// The right dock of the dock_area.
|
||||
right_dock: Option<View<Dock>>,
|
||||
/// The top zoom view of the dock_area, if any.
|
||||
zoom_view: Option<AnyView>,
|
||||
|
||||
/// Lock panels layout, but allow to resize.
|
||||
is_locked: bool,
|
||||
|
||||
/// The panel style, default is [`PanelStyle::Default`](PanelStyle::Default).
|
||||
pub(crate) panel_style: PanelStyle,
|
||||
|
||||
_subscriptions: Vec<Subscription>,
|
||||
}
|
||||
|
||||
/// DockItem is a tree structure that represents the layout of the dock.
|
||||
#[derive(Clone)]
|
||||
pub enum DockItem {
|
||||
/// Split layout
|
||||
Split {
|
||||
axis: gpui::Axis,
|
||||
items: Vec<DockItem>,
|
||||
sizes: Vec<Option<Pixels>>,
|
||||
view: View<StackPanel>,
|
||||
},
|
||||
/// Tab layout
|
||||
Tabs {
|
||||
items: Vec<Arc<dyn PanelView>>,
|
||||
active_ix: usize,
|
||||
view: View<TabPanel>,
|
||||
},
|
||||
/// Panel layout
|
||||
Panel { view: Arc<dyn PanelView> },
|
||||
}
|
||||
|
||||
impl DockItem {
|
||||
/// Create DockItem with split layout, each item of panel have equal size.
|
||||
pub fn split(
|
||||
axis: Axis,
|
||||
items: Vec<DockItem>,
|
||||
dock_area: &WeakView<DockArea>,
|
||||
cx: &mut WindowContext,
|
||||
) -> Self {
|
||||
let sizes = vec![None; items.len()];
|
||||
Self::split_with_sizes(axis, items, sizes, dock_area, cx)
|
||||
}
|
||||
|
||||
/// Create DockItem with split layout, each item of panel have specified size.
|
||||
///
|
||||
/// Please note that the `items` and `sizes` must have the same length.
|
||||
/// Set `None` in `sizes` to make the index of panel have auto size.
|
||||
pub fn split_with_sizes(
|
||||
axis: Axis,
|
||||
items: Vec<DockItem>,
|
||||
sizes: Vec<Option<Pixels>>,
|
||||
dock_area: &WeakView<DockArea>,
|
||||
cx: &mut WindowContext,
|
||||
) -> Self {
|
||||
let mut items = items;
|
||||
let stack_panel = cx.new_view(|cx| {
|
||||
let mut stack_panel = StackPanel::new(axis, cx);
|
||||
for (i, item) in items.iter_mut().enumerate() {
|
||||
let view = item.view();
|
||||
let size = sizes.get(i).copied().flatten();
|
||||
stack_panel.add_panel(view.clone(), size, dock_area.clone(), cx)
|
||||
}
|
||||
|
||||
for (i, item) in items.iter().enumerate() {
|
||||
let view = item.view();
|
||||
let size = sizes.get(i).copied().flatten();
|
||||
stack_panel.add_panel(view.clone(), size, dock_area.clone(), cx)
|
||||
}
|
||||
|
||||
stack_panel
|
||||
});
|
||||
|
||||
cx.defer({
|
||||
let stack_panel = stack_panel.clone();
|
||||
let dock_area = dock_area.clone();
|
||||
move |cx| {
|
||||
_ = dock_area.update(cx, |this, cx| {
|
||||
this.subscribe_panel(&stack_panel, cx);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
Self::Split {
|
||||
axis,
|
||||
items,
|
||||
sizes,
|
||||
view: stack_panel,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create DockItem with panel layout
|
||||
pub fn panel(panel: Arc<dyn PanelView>) -> Self {
|
||||
Self::Panel { view: panel }
|
||||
}
|
||||
|
||||
/// Create DockItem with tabs layout, items are displayed as tabs.
|
||||
///
|
||||
/// The `active_ix` is the index of the active tab, if `None` the first tab is active.
|
||||
pub fn tabs(
|
||||
items: Vec<Arc<dyn PanelView>>,
|
||||
active_ix: Option<usize>,
|
||||
dock_area: &WeakView<DockArea>,
|
||||
cx: &mut WindowContext,
|
||||
) -> Self {
|
||||
let mut new_items: Vec<Arc<dyn PanelView>> = vec![];
|
||||
for item in items.into_iter() {
|
||||
new_items.push(item)
|
||||
}
|
||||
Self::new_tabs(new_items, active_ix, dock_area, cx)
|
||||
}
|
||||
|
||||
pub fn tab<P: Panel>(
|
||||
item: View<P>,
|
||||
dock_area: &WeakView<DockArea>,
|
||||
cx: &mut WindowContext,
|
||||
) -> Self {
|
||||
Self::new_tabs(vec![Arc::new(item.clone())], None, dock_area, cx)
|
||||
}
|
||||
|
||||
fn new_tabs(
|
||||
items: Vec<Arc<dyn PanelView>>,
|
||||
active_ix: Option<usize>,
|
||||
dock_area: &WeakView<DockArea>,
|
||||
cx: &mut WindowContext,
|
||||
) -> Self {
|
||||
let active_ix = active_ix.unwrap_or(0);
|
||||
let tab_panel = cx.new_view(|cx| {
|
||||
let mut tab_panel = TabPanel::new(None, dock_area.clone(), cx);
|
||||
for item in items.iter() {
|
||||
tab_panel.add_panel(item.clone(), cx)
|
||||
}
|
||||
tab_panel.active_ix = active_ix;
|
||||
tab_panel
|
||||
});
|
||||
|
||||
Self::Tabs {
|
||||
items,
|
||||
active_ix,
|
||||
view: tab_panel,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the views of the dock item.
|
||||
fn view(&self) -> Arc<dyn PanelView> {
|
||||
match self {
|
||||
Self::Split { view, .. } => Arc::new(view.clone()),
|
||||
Self::Tabs { view, .. } => Arc::new(view.clone()),
|
||||
Self::Panel { view, .. } => view.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Find existing panel in the dock item.
|
||||
pub fn find_panel(&self, panel: Arc<dyn PanelView>) -> Option<Arc<dyn PanelView>> {
|
||||
match self {
|
||||
Self::Split { items, .. } => {
|
||||
items.iter().find_map(|item| item.find_panel(panel.clone()))
|
||||
}
|
||||
Self::Tabs { items, .. } => items.iter().find(|item| *item == &panel).cloned(),
|
||||
Self::Panel { view } => Some(view.clone()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Add a panel to the dock item.
|
||||
pub fn add_panel(
|
||||
&mut self,
|
||||
panel: Arc<dyn PanelView>,
|
||||
dock_area: &WeakView<DockArea>,
|
||||
cx: &mut WindowContext,
|
||||
) {
|
||||
match self {
|
||||
Self::Tabs { view, items, .. } => {
|
||||
items.push(panel.clone());
|
||||
view.update(cx, |tab_panel, cx| {
|
||||
tab_panel.add_panel(panel, cx);
|
||||
});
|
||||
}
|
||||
Self::Split { view, items, .. } => {
|
||||
// Iter items to add panel to the first tabs
|
||||
for item in items.into_iter() {
|
||||
if let DockItem::Tabs { view, .. } = item {
|
||||
view.update(cx, |tab_panel, cx| {
|
||||
tab_panel.add_panel(panel.clone(), cx);
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Unable to find tabs, create new tabs
|
||||
let new_item = Self::tabs(vec![panel.clone()], None, dock_area, cx);
|
||||
items.push(new_item.clone());
|
||||
view.update(cx, |stack_panel, cx| {
|
||||
stack_panel.add_panel(new_item.view(), None, dock_area.clone(), cx);
|
||||
});
|
||||
}
|
||||
Self::Panel { .. } => {}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_collapsed(&self, collapsed: bool, cx: &mut WindowContext) {
|
||||
match self {
|
||||
DockItem::Tabs { view, .. } => {
|
||||
view.update(cx, |tab_panel, cx| {
|
||||
tab_panel.set_collapsed(collapsed, cx);
|
||||
});
|
||||
}
|
||||
DockItem::Split { items, .. } => {
|
||||
// For each child item, set collapsed state
|
||||
for item in items {
|
||||
item.set_collapsed(collapsed, cx);
|
||||
}
|
||||
}
|
||||
DockItem::Panel { .. } => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Recursively traverses to find the left-most and top-most TabPanel.
|
||||
pub(crate) fn left_top_tab_panel(&self, cx: &AppContext) -> Option<View<TabPanel>> {
|
||||
match self {
|
||||
DockItem::Tabs { view, .. } => Some(view.clone()),
|
||||
DockItem::Split { view, .. } => view.read(cx).left_top_tab_panel(true, cx),
|
||||
DockItem::Panel { .. } => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Recursively traverses to find the right-most and top-most TabPanel.
|
||||
pub(crate) fn right_top_tab_panel(&self, cx: &AppContext) -> Option<View<TabPanel>> {
|
||||
match self {
|
||||
DockItem::Tabs { view, .. } => Some(view.clone()),
|
||||
DockItem::Split { view, .. } => view.read(cx).right_top_tab_panel(true, cx),
|
||||
DockItem::Panel { .. } => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl DockArea {
|
||||
pub fn new(
|
||||
id: impl Into<SharedString>,
|
||||
version: Option<usize>,
|
||||
cx: &mut ViewContext<Self>,
|
||||
) -> Self {
|
||||
let stack_panel = cx.new_view(|cx| StackPanel::new(Axis::Horizontal, cx));
|
||||
|
||||
let dock_item = DockItem::Split {
|
||||
axis: Axis::Horizontal,
|
||||
items: vec![],
|
||||
sizes: vec![],
|
||||
view: stack_panel.clone(),
|
||||
};
|
||||
|
||||
let mut this = Self {
|
||||
id: id.into(),
|
||||
version,
|
||||
bounds: Bounds::default(),
|
||||
items: dock_item,
|
||||
zoom_view: None,
|
||||
toggle_button_panels: Edges::default(),
|
||||
left_dock: None,
|
||||
right_dock: None,
|
||||
bottom_dock: None,
|
||||
is_locked: false,
|
||||
panel_style: PanelStyle::Default,
|
||||
_subscriptions: vec![],
|
||||
};
|
||||
|
||||
this.subscribe_panel(&stack_panel, cx);
|
||||
|
||||
this
|
||||
}
|
||||
|
||||
/// Set the panel style of the dock area.
|
||||
pub fn panel_style(mut self, style: PanelStyle) -> Self {
|
||||
self.panel_style = style;
|
||||
self
|
||||
}
|
||||
|
||||
/// Set version of the dock area.
|
||||
pub fn set_version(&mut self, version: usize, cx: &mut ViewContext<Self>) {
|
||||
self.version = Some(version);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
// FIXME: Remove this method after 2025-01-01
|
||||
#[deprecated(note = "Use `set_center` instead")]
|
||||
pub fn set_root(&mut self, item: DockItem, cx: &mut ViewContext<Self>) {
|
||||
self.set_center(item, cx);
|
||||
}
|
||||
|
||||
/// The the DockItem as the center of the dock area.
|
||||
///
|
||||
/// This is used to render at the Center of the DockArea.
|
||||
pub fn set_center(&mut self, item: DockItem, cx: &mut ViewContext<Self>) {
|
||||
self.subscribe_item(&item, cx);
|
||||
self.items = item;
|
||||
self.update_toggle_button_tab_panels(cx);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub fn set_left_dock(
|
||||
&mut self,
|
||||
panel: DockItem,
|
||||
size: Option<Pixels>,
|
||||
open: bool,
|
||||
cx: &mut ViewContext<Self>,
|
||||
) {
|
||||
self.subscribe_item(&panel, cx);
|
||||
let weak_self = cx.view().downgrade();
|
||||
self.left_dock = Some(cx.new_view(|cx| {
|
||||
let mut dock = Dock::left(weak_self.clone(), cx);
|
||||
if let Some(size) = size {
|
||||
dock.set_size(size, cx);
|
||||
}
|
||||
dock.set_panel(panel, cx);
|
||||
dock.set_open(open, cx);
|
||||
dock
|
||||
}));
|
||||
self.update_toggle_button_tab_panels(cx);
|
||||
}
|
||||
|
||||
pub fn set_bottom_dock(
|
||||
&mut self,
|
||||
panel: DockItem,
|
||||
size: Option<Pixels>,
|
||||
open: bool,
|
||||
cx: &mut ViewContext<Self>,
|
||||
) {
|
||||
self.subscribe_item(&panel, cx);
|
||||
let weak_self = cx.view().downgrade();
|
||||
self.bottom_dock = Some(cx.new_view(|cx| {
|
||||
let mut dock = Dock::bottom(weak_self.clone(), cx);
|
||||
if let Some(size) = size {
|
||||
dock.set_size(size, cx);
|
||||
}
|
||||
dock.set_panel(panel, cx);
|
||||
dock.set_open(open, cx);
|
||||
dock
|
||||
}));
|
||||
self.update_toggle_button_tab_panels(cx);
|
||||
}
|
||||
|
||||
pub fn set_right_dock(
|
||||
&mut self,
|
||||
panel: DockItem,
|
||||
size: Option<Pixels>,
|
||||
open: bool,
|
||||
cx: &mut ViewContext<Self>,
|
||||
) {
|
||||
self.subscribe_item(&panel, cx);
|
||||
let weak_self = cx.view().downgrade();
|
||||
self.right_dock = Some(cx.new_view(|cx| {
|
||||
let mut dock = Dock::right(weak_self.clone(), cx);
|
||||
if let Some(size) = size {
|
||||
dock.set_size(size, cx);
|
||||
}
|
||||
dock.set_panel(panel, cx);
|
||||
dock.set_open(open, cx);
|
||||
dock
|
||||
}));
|
||||
self.update_toggle_button_tab_panels(cx);
|
||||
}
|
||||
|
||||
/// Set locked state of the dock area, if locked, the dock area cannot be split or move, but allows to resize panels.
|
||||
pub fn set_locked(&mut self, locked: bool, _: &mut WindowContext) {
|
||||
self.is_locked = locked;
|
||||
}
|
||||
|
||||
/// Determine if the dock area is locked.
|
||||
pub fn is_locked(&self) -> bool {
|
||||
self.is_locked
|
||||
}
|
||||
|
||||
/// Determine if the dock area has a dock at the given placement.
|
||||
pub fn has_dock(&self, placement: DockPlacement) -> bool {
|
||||
match placement {
|
||||
DockPlacement::Left => self.left_dock.is_some(),
|
||||
DockPlacement::Bottom => self.bottom_dock.is_some(),
|
||||
DockPlacement::Right => self.right_dock.is_some(),
|
||||
DockPlacement::Center => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Determine if the dock at the given placement is open.
|
||||
pub fn is_dock_open(&self, placement: DockPlacement, cx: &AppContext) -> bool {
|
||||
match placement {
|
||||
DockPlacement::Left => self
|
||||
.left_dock
|
||||
.as_ref()
|
||||
.map(|dock| dock.read(cx).is_open())
|
||||
.unwrap_or(false),
|
||||
DockPlacement::Bottom => self
|
||||
.bottom_dock
|
||||
.as_ref()
|
||||
.map(|dock| dock.read(cx).is_open())
|
||||
.unwrap_or(false),
|
||||
DockPlacement::Right => self
|
||||
.right_dock
|
||||
.as_ref()
|
||||
.map(|dock| dock.read(cx).is_open())
|
||||
.unwrap_or(false),
|
||||
DockPlacement::Center => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the dock at the given placement to be open or closed.
|
||||
///
|
||||
/// Only the left, bottom, right dock can be toggled.
|
||||
pub fn set_dock_collapsible(
|
||||
&mut self,
|
||||
collapsible_edges: Edges<bool>,
|
||||
cx: &mut ViewContext<Self>,
|
||||
) {
|
||||
if let Some(left_dock) = self.left_dock.as_ref() {
|
||||
left_dock.update(cx, |dock, cx| {
|
||||
dock.set_collapsible(collapsible_edges.left, cx);
|
||||
});
|
||||
}
|
||||
|
||||
if let Some(bottom_dock) = self.bottom_dock.as_ref() {
|
||||
bottom_dock.update(cx, |dock, cx| {
|
||||
dock.set_collapsible(collapsible_edges.bottom, cx);
|
||||
});
|
||||
}
|
||||
|
||||
if let Some(right_dock) = self.right_dock.as_ref() {
|
||||
right_dock.update(cx, |dock, cx| {
|
||||
dock.set_collapsible(collapsible_edges.right, cx);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Determine if the dock at the given placement is collapsible.
|
||||
pub fn is_dock_collapsible(&self, placement: DockPlacement, cx: &AppContext) -> bool {
|
||||
match placement {
|
||||
DockPlacement::Left => self
|
||||
.left_dock
|
||||
.as_ref()
|
||||
.map(|dock| dock.read(cx).collapsible)
|
||||
.unwrap_or(false),
|
||||
DockPlacement::Bottom => self
|
||||
.bottom_dock
|
||||
.as_ref()
|
||||
.map(|dock| dock.read(cx).collapsible)
|
||||
.unwrap_or(false),
|
||||
DockPlacement::Right => self
|
||||
.right_dock
|
||||
.as_ref()
|
||||
.map(|dock| dock.read(cx).collapsible)
|
||||
.unwrap_or(false),
|
||||
DockPlacement::Center => false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn toggle_dock(&self, placement: DockPlacement, cx: &mut ViewContext<Self>) {
|
||||
let dock = match placement {
|
||||
DockPlacement::Left => &self.left_dock,
|
||||
DockPlacement::Bottom => &self.bottom_dock,
|
||||
DockPlacement::Right => &self.right_dock,
|
||||
DockPlacement::Center => return,
|
||||
};
|
||||
|
||||
if let Some(dock) = dock {
|
||||
dock.update(cx, |view, cx| {
|
||||
view.toggle_open(cx);
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Add a panel item to the dock area at the given placement.
|
||||
///
|
||||
/// If the left, bottom, right dock is not present, it will set the dock at the placement.
|
||||
pub fn add_panel(
|
||||
&mut self,
|
||||
panel: Arc<dyn PanelView>,
|
||||
placement: DockPlacement,
|
||||
cx: &mut ViewContext<Self>,
|
||||
) {
|
||||
let weak_self = cx.view().downgrade();
|
||||
match placement {
|
||||
DockPlacement::Left => {
|
||||
if let Some(dock) = self.left_dock.as_ref() {
|
||||
dock.update(cx, |dock, cx| dock.add_panel(panel, cx))
|
||||
} else {
|
||||
self.set_left_dock(
|
||||
DockItem::tabs(vec![panel], None, &weak_self, cx),
|
||||
None,
|
||||
true,
|
||||
cx,
|
||||
);
|
||||
}
|
||||
}
|
||||
DockPlacement::Bottom => {
|
||||
if let Some(dock) = self.bottom_dock.as_ref() {
|
||||
dock.update(cx, |dock, cx| dock.add_panel(panel, cx))
|
||||
} else {
|
||||
self.set_bottom_dock(
|
||||
DockItem::tabs(vec![panel], None, &weak_self, cx),
|
||||
None,
|
||||
true,
|
||||
cx,
|
||||
);
|
||||
}
|
||||
}
|
||||
DockPlacement::Right => {
|
||||
if let Some(dock) = self.right_dock.as_ref() {
|
||||
dock.update(cx, |dock, cx| dock.add_panel(panel, cx))
|
||||
} else {
|
||||
self.set_right_dock(
|
||||
DockItem::tabs(vec![panel], None, &weak_self, cx),
|
||||
None,
|
||||
true,
|
||||
cx,
|
||||
);
|
||||
}
|
||||
}
|
||||
DockPlacement::Center => {
|
||||
self.items.add_panel(panel, &cx.view().downgrade(), cx);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Load the state of the DockArea from the DockAreaState.
|
||||
///
|
||||
/// See also [DockeArea::dump].
|
||||
pub fn load(&mut self, state: DockAreaState, cx: &mut ViewContext<Self>) -> Result<()> {
|
||||
self.version = state.version;
|
||||
let weak_self = cx.view().downgrade();
|
||||
|
||||
if let Some(left_dock_state) = state.left_dock {
|
||||
self.left_dock = Some(left_dock_state.to_dock(weak_self.clone(), cx));
|
||||
}
|
||||
|
||||
if let Some(right_dock_state) = state.right_dock {
|
||||
self.right_dock = Some(right_dock_state.to_dock(weak_self.clone(), cx));
|
||||
}
|
||||
|
||||
if let Some(bottom_dock_state) = state.bottom_dock {
|
||||
self.bottom_dock = Some(bottom_dock_state.to_dock(weak_self.clone(), cx));
|
||||
}
|
||||
|
||||
self.items = state.center.to_item(weak_self, cx);
|
||||
self.update_toggle_button_tab_panels(cx);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Dump the dock panels layout to DockItemState.
|
||||
///
|
||||
/// See also [DockArea::load].
|
||||
pub fn dump(&self, cx: &AppContext) -> DockAreaState {
|
||||
let root = self.items.view();
|
||||
let center = root.dump(cx);
|
||||
|
||||
let left_dock = self
|
||||
.left_dock
|
||||
.as_ref()
|
||||
.map(|dock| DockState::new(dock.clone(), cx));
|
||||
let right_dock = self
|
||||
.right_dock
|
||||
.as_ref()
|
||||
.map(|dock| DockState::new(dock.clone(), cx));
|
||||
let bottom_dock = self
|
||||
.bottom_dock
|
||||
.as_ref()
|
||||
.map(|dock| DockState::new(dock.clone(), cx));
|
||||
|
||||
DockAreaState {
|
||||
version: self.version,
|
||||
center,
|
||||
left_dock,
|
||||
right_dock,
|
||||
bottom_dock,
|
||||
}
|
||||
}
|
||||
|
||||
/// Subscribe event on the panels
|
||||
#[allow(clippy::only_used_in_recursion)]
|
||||
fn subscribe_item(&mut self, item: &DockItem, cx: &mut ViewContext<Self>) {
|
||||
match item {
|
||||
DockItem::Split { items, view, .. } => {
|
||||
for item in items {
|
||||
self.subscribe_item(item, cx);
|
||||
}
|
||||
|
||||
self._subscriptions
|
||||
.push(cx.subscribe(view, move |_, _, event, cx| match event {
|
||||
PanelEvent::LayoutChanged => {
|
||||
let dock_area = cx.view().clone();
|
||||
cx.spawn(|_, mut cx| async move {
|
||||
let _ = cx.update(|cx| {
|
||||
let _ = dock_area.update(cx, |view, cx| {
|
||||
view.update_toggle_button_tab_panels(cx)
|
||||
});
|
||||
});
|
||||
})
|
||||
.detach();
|
||||
cx.emit(DockEvent::LayoutChanged);
|
||||
}
|
||||
_ => {}
|
||||
}));
|
||||
}
|
||||
DockItem::Tabs { .. } => {
|
||||
// We subscribe to the tab panel event in StackPanel's insert_panel
|
||||
}
|
||||
DockItem::Panel { .. } => {
|
||||
// Not supported
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Subscribe zoom event on the panel
|
||||
pub(crate) fn subscribe_panel<P: Panel>(
|
||||
&mut self,
|
||||
view: &View<P>,
|
||||
cx: &mut ViewContext<DockArea>,
|
||||
) {
|
||||
let subscription = cx.subscribe(view, move |_, panel, event, cx| match event {
|
||||
PanelEvent::ZoomIn => {
|
||||
let dock_area = cx.view().clone();
|
||||
let panel = panel.clone();
|
||||
cx.spawn(|_, mut cx| async move {
|
||||
let _ = cx.update(|cx| {
|
||||
let _ = dock_area.update(cx, |dock, cx| {
|
||||
dock.set_zoomed_in(panel, cx);
|
||||
cx.notify();
|
||||
});
|
||||
});
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
PanelEvent::ZoomOut => {
|
||||
let dock_area = cx.view().clone();
|
||||
cx.spawn(|_, mut cx| async move {
|
||||
let _ = cx.update(|cx| {
|
||||
let _ = dock_area.update(cx, |view, cx| view.set_zoomed_out(cx));
|
||||
});
|
||||
})
|
||||
.detach()
|
||||
}
|
||||
PanelEvent::LayoutChanged => {
|
||||
let dock_area = cx.view().clone();
|
||||
cx.spawn(|_, mut cx| async move {
|
||||
let _ = cx.update(|cx| {
|
||||
let _ = dock_area
|
||||
.update(cx, |view, cx| view.update_toggle_button_tab_panels(cx));
|
||||
});
|
||||
})
|
||||
.detach();
|
||||
cx.emit(DockEvent::LayoutChanged);
|
||||
}
|
||||
});
|
||||
|
||||
self._subscriptions.push(subscription);
|
||||
}
|
||||
|
||||
/// Returns the ID of the dock area.
|
||||
pub fn id(&self) -> SharedString {
|
||||
self.id.clone()
|
||||
}
|
||||
|
||||
pub fn set_zoomed_in<P: Panel>(&mut self, panel: View<P>, cx: &mut ViewContext<Self>) {
|
||||
self.zoom_view = Some(panel.into());
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub fn set_zoomed_out(&mut self, cx: &mut ViewContext<Self>) {
|
||||
self.zoom_view = None;
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn render_items(&self, _cx: &mut ViewContext<Self>) -> AnyElement {
|
||||
match &self.items {
|
||||
DockItem::Split { view, .. } => view.clone().into_any_element(),
|
||||
DockItem::Tabs { view, .. } => view.clone().into_any_element(),
|
||||
DockItem::Panel { view, .. } => view.clone().view().into_any_element(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn update_toggle_button_tab_panels(&mut self, cx: &mut ViewContext<Self>) {
|
||||
// Left toggle button
|
||||
self.toggle_button_panels.left = self
|
||||
.items
|
||||
.left_top_tab_panel(cx)
|
||||
.map(|view| view.entity_id());
|
||||
|
||||
// Right toggle button
|
||||
self.toggle_button_panels.right = self
|
||||
.items
|
||||
.right_top_tab_panel(cx)
|
||||
.map(|view| view.entity_id());
|
||||
|
||||
// Bottom toggle button
|
||||
self.toggle_button_panels.bottom = self
|
||||
.bottom_dock
|
||||
.as_ref()
|
||||
.and_then(|dock| dock.read(cx).panel.left_top_tab_panel(cx))
|
||||
.map(|view| view.entity_id());
|
||||
}
|
||||
}
|
||||
impl EventEmitter<DockEvent> for DockArea {}
|
||||
impl Render for DockArea {
|
||||
fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
|
||||
let view = cx.view().clone();
|
||||
|
||||
div()
|
||||
.id("dock-area")
|
||||
.relative()
|
||||
.size_full()
|
||||
.overflow_hidden()
|
||||
.child(
|
||||
canvas(
|
||||
move |bounds, cx| view.update(cx, |r, _| r.bounds = bounds),
|
||||
|_, _, _| {},
|
||||
)
|
||||
.absolute()
|
||||
.size_full(),
|
||||
)
|
||||
.map(|this| {
|
||||
if let Some(zoom_view) = self.zoom_view.clone() {
|
||||
this.child(zoom_view)
|
||||
} else {
|
||||
this.child(
|
||||
div()
|
||||
.flex()
|
||||
.flex_row()
|
||||
.h_full()
|
||||
// Left dock
|
||||
.when_some(self.left_dock.clone(), |this, dock| {
|
||||
this.child(div().flex().flex_none().child(dock))
|
||||
})
|
||||
// Center
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.flex_1()
|
||||
.flex_col()
|
||||
.overflow_hidden()
|
||||
// Top center
|
||||
.child(
|
||||
div()
|
||||
.flex_1()
|
||||
.overflow_hidden()
|
||||
.child(self.render_items(cx)),
|
||||
)
|
||||
// Bottom Dock
|
||||
.when_some(self.bottom_dock.clone(), |this, dock| {
|
||||
this.child(dock)
|
||||
}),
|
||||
)
|
||||
// Right Dock
|
||||
.when_some(self.right_dock.clone(), |this, dock| {
|
||||
this.child(div().flex().flex_none().child(dock))
|
||||
}),
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
186
crates/ui/src/dock/panel.rs
Normal file
186
crates/ui/src/dock/panel.rs
Normal file
@@ -0,0 +1,186 @@
|
||||
use gpui::{
|
||||
AnyElement, AnyView, AppContext, EventEmitter, FocusHandle, FocusableView, Global, Hsla,
|
||||
IntoElement, SharedString, View, WeakView, WindowContext,
|
||||
};
|
||||
use std::{collections::HashMap, sync::Arc};
|
||||
|
||||
use super::{DockArea, DockItemInfo, DockItemState};
|
||||
use crate::{button::Button, popup_menu::PopupMenu};
|
||||
|
||||
pub enum PanelEvent {
|
||||
ZoomIn,
|
||||
ZoomOut,
|
||||
LayoutChanged,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum PanelStyle {
|
||||
/// Display the TabBar when there are multiple tabs, otherwise display the simple title.
|
||||
Default,
|
||||
/// Always display the tab bar.
|
||||
TabBar,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct TitleStyle {
|
||||
pub background: Hsla,
|
||||
pub foreground: Hsla,
|
||||
}
|
||||
|
||||
pub trait Panel: EventEmitter<PanelEvent> + FocusableView {
|
||||
/// The name of the panel used to serialize, deserialize and identify the panel.
|
||||
///
|
||||
/// This is used to identify the panel when deserializing the panel.
|
||||
/// Once you have defined a panel name, this must not be changed.
|
||||
fn panel_name(&self) -> &'static str;
|
||||
|
||||
/// The title of the panel
|
||||
fn title(&self, _cx: &WindowContext) -> AnyElement {
|
||||
SharedString::from("Unnamed").into_any_element()
|
||||
}
|
||||
|
||||
/// The theme of the panel title, default is `None`.
|
||||
fn title_style(&self, _cx: &WindowContext) -> Option<TitleStyle> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Whether the panel can be closed, default is `true`.
|
||||
fn closeable(&self, _cx: &WindowContext) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
/// Return true if the panel is zoomable, default is `false`.
|
||||
fn zoomable(&self, _cx: &WindowContext) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
/// The addition popup menu of the panel, default is `None`.
|
||||
fn popup_menu(&self, this: PopupMenu, _cx: &WindowContext) -> PopupMenu {
|
||||
this
|
||||
}
|
||||
|
||||
/// The addition toolbar buttons of the panel used to show in the right of the title bar, default is `None`.
|
||||
fn toolbar_buttons(&self, _cx: &WindowContext) -> Vec<Button> {
|
||||
vec![]
|
||||
}
|
||||
|
||||
/// Dump the panel, used to serialize the panel.
|
||||
fn dump(&self, _cx: &AppContext) -> DockItemState {
|
||||
DockItemState::new(self)
|
||||
}
|
||||
}
|
||||
|
||||
pub trait PanelView: 'static + Send + Sync {
|
||||
fn panel_name(&self, _cx: &WindowContext) -> &'static str;
|
||||
fn title(&self, _cx: &WindowContext) -> AnyElement;
|
||||
fn title_style(&self, _cx: &WindowContext) -> Option<TitleStyle>;
|
||||
fn closeable(&self, cx: &WindowContext) -> bool;
|
||||
fn zoomable(&self, cx: &WindowContext) -> bool;
|
||||
fn popup_menu(&self, menu: PopupMenu, cx: &WindowContext) -> PopupMenu;
|
||||
fn toolbar_buttons(&self, cx: &WindowContext) -> Vec<Button>;
|
||||
fn view(&self) -> AnyView;
|
||||
fn focus_handle(&self, cx: &AppContext) -> FocusHandle;
|
||||
fn dump(&self, cx: &AppContext) -> DockItemState;
|
||||
}
|
||||
|
||||
impl<T: Panel> PanelView for View<T> {
|
||||
fn panel_name(&self, cx: &WindowContext) -> &'static str {
|
||||
self.read(cx).panel_name()
|
||||
}
|
||||
|
||||
fn title(&self, cx: &WindowContext) -> AnyElement {
|
||||
self.read(cx).title(cx)
|
||||
}
|
||||
|
||||
fn title_style(&self, cx: &WindowContext) -> Option<TitleStyle> {
|
||||
self.read(cx).title_style(cx)
|
||||
}
|
||||
|
||||
fn closeable(&self, cx: &WindowContext) -> bool {
|
||||
self.read(cx).closeable(cx)
|
||||
}
|
||||
|
||||
fn zoomable(&self, cx: &WindowContext) -> bool {
|
||||
self.read(cx).zoomable(cx)
|
||||
}
|
||||
|
||||
fn popup_menu(&self, menu: PopupMenu, cx: &WindowContext) -> PopupMenu {
|
||||
self.read(cx).popup_menu(menu, cx)
|
||||
}
|
||||
|
||||
fn toolbar_buttons(&self, cx: &WindowContext) -> Vec<Button> {
|
||||
self.read(cx).toolbar_buttons(cx)
|
||||
}
|
||||
|
||||
fn view(&self) -> AnyView {
|
||||
self.clone().into()
|
||||
}
|
||||
|
||||
fn focus_handle(&self, cx: &AppContext) -> FocusHandle {
|
||||
self.read(cx).focus_handle(cx)
|
||||
}
|
||||
|
||||
fn dump(&self, cx: &AppContext) -> DockItemState {
|
||||
self.read(cx).dump(cx)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&dyn PanelView> for AnyView {
|
||||
fn from(handle: &dyn PanelView) -> Self {
|
||||
handle.view()
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: Panel> From<&dyn PanelView> for View<T> {
|
||||
fn from(value: &dyn PanelView) -> Self {
|
||||
value.view().downcast::<T>().unwrap()
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialEq for dyn PanelView {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
self.view() == other.view()
|
||||
}
|
||||
}
|
||||
|
||||
pub struct PanelRegistry {
|
||||
pub(super) items: HashMap<
|
||||
String,
|
||||
Arc<
|
||||
dyn Fn(
|
||||
WeakView<DockArea>,
|
||||
&DockItemState,
|
||||
&DockItemInfo,
|
||||
&mut WindowContext,
|
||||
) -> Box<dyn PanelView>,
|
||||
>,
|
||||
>,
|
||||
}
|
||||
impl PanelRegistry {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
items: HashMap::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
impl Global for PanelRegistry {}
|
||||
|
||||
/// Register the Panel init by panel_name to global registry.
|
||||
pub fn register_panel<F>(cx: &mut AppContext, panel_name: &str, deserialize: F)
|
||||
where
|
||||
F: Fn(
|
||||
WeakView<DockArea>,
|
||||
&DockItemState,
|
||||
&DockItemInfo,
|
||||
&mut WindowContext,
|
||||
) -> Box<dyn PanelView>
|
||||
+ 'static,
|
||||
{
|
||||
if let None = cx.try_global::<PanelRegistry>() {
|
||||
cx.set_global(PanelRegistry::new());
|
||||
}
|
||||
|
||||
cx.global_mut::<PanelRegistry>()
|
||||
.items
|
||||
.insert(panel_name.to_string(), Arc::new(deserialize));
|
||||
}
|
||||
379
crates/ui/src/dock/stack_panel.rs
Normal file
379
crates/ui/src/dock/stack_panel.rs
Normal file
@@ -0,0 +1,379 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::{
|
||||
dock::DockItemInfo,
|
||||
h_flex,
|
||||
resizable::{
|
||||
h_resizable, resizable_panel, v_resizable, ResizablePanel, ResizablePanelEvent,
|
||||
ResizablePanelGroup,
|
||||
},
|
||||
theme::ActiveTheme,
|
||||
AxisExt as _, Placement,
|
||||
};
|
||||
|
||||
use super::{DockArea, DockItemState, Panel, PanelEvent, PanelView, TabPanel};
|
||||
use gpui::{
|
||||
prelude::FluentBuilder as _, AppContext, Axis, DismissEvent, EventEmitter, FocusHandle,
|
||||
FocusableView, IntoElement, ParentElement, Pixels, Render, Styled, Subscription, View,
|
||||
ViewContext, VisualContext, WeakView,
|
||||
};
|
||||
use smallvec::SmallVec;
|
||||
|
||||
pub struct StackPanel {
|
||||
pub(super) parent: Option<WeakView<StackPanel>>,
|
||||
pub(super) axis: Axis,
|
||||
focus_handle: FocusHandle,
|
||||
pub(crate) panels: SmallVec<[Arc<dyn PanelView>; 2]>,
|
||||
panel_group: View<ResizablePanelGroup>,
|
||||
_subscriptions: Vec<Subscription>,
|
||||
}
|
||||
|
||||
impl Panel for StackPanel {
|
||||
fn panel_name(&self) -> &'static str {
|
||||
"StackPanel"
|
||||
}
|
||||
|
||||
fn title(&self, _cx: &gpui::WindowContext) -> gpui::AnyElement {
|
||||
"StackPanel".into_any_element()
|
||||
}
|
||||
|
||||
fn dump(&self, cx: &AppContext) -> DockItemState {
|
||||
let sizes = self.panel_group.read(cx).sizes();
|
||||
let mut state = DockItemState::new(self);
|
||||
for panel in &self.panels {
|
||||
state.add_child(panel.dump(cx));
|
||||
state.info = DockItemInfo::stack(sizes.clone(), self.axis);
|
||||
}
|
||||
|
||||
state
|
||||
}
|
||||
}
|
||||
|
||||
impl StackPanel {
|
||||
pub fn new(axis: Axis, cx: &mut ViewContext<Self>) -> Self {
|
||||
let panel_group = cx.new_view(|cx| {
|
||||
if axis == Axis::Horizontal {
|
||||
h_resizable(cx)
|
||||
} else {
|
||||
v_resizable(cx)
|
||||
}
|
||||
});
|
||||
|
||||
// Bubble up the resize event.
|
||||
let _subscriptions = vec![cx
|
||||
.subscribe(&panel_group, |_, _, _: &ResizablePanelEvent, cx| {
|
||||
cx.emit(PanelEvent::LayoutChanged)
|
||||
})];
|
||||
|
||||
Self {
|
||||
axis,
|
||||
parent: None,
|
||||
focus_handle: cx.focus_handle(),
|
||||
panels: SmallVec::new(),
|
||||
panel_group,
|
||||
_subscriptions,
|
||||
}
|
||||
}
|
||||
|
||||
/// The first level of the stack panel is root, will not have a parent.
|
||||
fn is_root(&self) -> bool {
|
||||
self.parent.is_none()
|
||||
}
|
||||
|
||||
/// Return true if self or parent only have last panel.
|
||||
pub(super) fn is_last_panel(&self, cx: &AppContext) -> bool {
|
||||
if self.panels.len() > 1 {
|
||||
return false;
|
||||
}
|
||||
|
||||
if let Some(parent) = &self.parent {
|
||||
if let Some(parent) = parent.upgrade() {
|
||||
return parent.read(cx).is_last_panel(cx);
|
||||
}
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
pub(super) fn panels_len(&self) -> usize {
|
||||
self.panels.len()
|
||||
}
|
||||
|
||||
/// Return the index of the panel.
|
||||
pub(crate) fn index_of_panel(&self, panel: Arc<dyn PanelView>) -> Option<usize> {
|
||||
self.panels.iter().position(|p| p == &panel)
|
||||
}
|
||||
|
||||
/// Add a panel at the end of the stack.
|
||||
pub fn add_panel(
|
||||
&mut self,
|
||||
panel: Arc<dyn PanelView>,
|
||||
size: Option<Pixels>,
|
||||
dock_area: WeakView<DockArea>,
|
||||
cx: &mut ViewContext<Self>,
|
||||
) {
|
||||
self.insert_panel(panel, self.panels.len(), size, dock_area, cx);
|
||||
}
|
||||
|
||||
pub fn add_panel_at(
|
||||
&mut self,
|
||||
panel: Arc<dyn PanelView>,
|
||||
placement: Placement,
|
||||
size: Option<Pixels>,
|
||||
dock_area: WeakView<DockArea>,
|
||||
cx: &mut ViewContext<Self>,
|
||||
) {
|
||||
self.insert_panel_at(panel, self.panels_len(), placement, size, dock_area, cx);
|
||||
}
|
||||
|
||||
pub fn insert_panel_at(
|
||||
&mut self,
|
||||
panel: Arc<dyn PanelView>,
|
||||
ix: usize,
|
||||
placement: Placement,
|
||||
size: Option<Pixels>,
|
||||
dock_area: WeakView<DockArea>,
|
||||
cx: &mut ViewContext<Self>,
|
||||
) {
|
||||
match placement {
|
||||
Placement::Top | Placement::Left => {
|
||||
self.insert_panel_before(panel, ix, size, dock_area, cx)
|
||||
}
|
||||
Placement::Right | Placement::Bottom => {
|
||||
self.insert_panel_after(panel, ix, size, dock_area, cx)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Insert a panel at the index.
|
||||
pub fn insert_panel_before(
|
||||
&mut self,
|
||||
panel: Arc<dyn PanelView>,
|
||||
ix: usize,
|
||||
size: Option<Pixels>,
|
||||
dock_area: WeakView<DockArea>,
|
||||
cx: &mut ViewContext<Self>,
|
||||
) {
|
||||
self.insert_panel(panel, ix, size, dock_area, cx);
|
||||
}
|
||||
|
||||
/// Insert a panel after the index.
|
||||
pub fn insert_panel_after(
|
||||
&mut self,
|
||||
panel: Arc<dyn PanelView>,
|
||||
ix: usize,
|
||||
size: Option<Pixels>,
|
||||
dock_area: WeakView<DockArea>,
|
||||
cx: &mut ViewContext<Self>,
|
||||
) {
|
||||
self.insert_panel(panel, ix + 1, size, dock_area, cx);
|
||||
}
|
||||
|
||||
fn new_resizable_panel(panel: Arc<dyn PanelView>, size: Option<Pixels>) -> ResizablePanel {
|
||||
resizable_panel()
|
||||
.content_view(panel.view())
|
||||
.when_some(size, |this, size| this.size(size))
|
||||
}
|
||||
|
||||
fn insert_panel(
|
||||
&mut self,
|
||||
panel: Arc<dyn PanelView>,
|
||||
ix: usize,
|
||||
size: Option<Pixels>,
|
||||
dock_area: WeakView<DockArea>,
|
||||
cx: &mut ViewContext<Self>,
|
||||
) {
|
||||
// If the panel is already in the stack, return.
|
||||
if let Some(_) = self.index_of_panel(panel.clone()) {
|
||||
return;
|
||||
}
|
||||
|
||||
let view = cx.view().clone();
|
||||
cx.window_context().defer({
|
||||
let panel = panel.clone();
|
||||
|
||||
move |cx| {
|
||||
// If the panel is a TabPanel, set its parent to this.
|
||||
if let Ok(tab_panel) = panel.view().downcast::<TabPanel>() {
|
||||
tab_panel.update(cx, |tab_panel, _| tab_panel.set_parent(view.downgrade()));
|
||||
} else if let Ok(stack_panel) = panel.view().downcast::<Self>() {
|
||||
stack_panel.update(cx, |stack_panel, _| {
|
||||
stack_panel.parent = Some(view.downgrade())
|
||||
});
|
||||
}
|
||||
|
||||
// Subscribe to the panel's layout change event.
|
||||
_ = dock_area.update(cx, |this, cx| {
|
||||
if let Ok(tab_panel) = panel.view().downcast::<TabPanel>() {
|
||||
this.subscribe_panel(&tab_panel, cx);
|
||||
} else if let Ok(stack_panel) = panel.view().downcast::<Self>() {
|
||||
this.subscribe_panel(&stack_panel, cx);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
let ix = if ix > self.panels.len() {
|
||||
self.panels.len()
|
||||
} else {
|
||||
ix
|
||||
};
|
||||
|
||||
self.panels.insert(ix, panel.clone());
|
||||
self.panel_group.update(cx, |view, cx| {
|
||||
view.insert_child(Self::new_resizable_panel(panel.clone(), size), ix, cx)
|
||||
});
|
||||
|
||||
cx.emit(PanelEvent::LayoutChanged);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Remove panel from the stack.
|
||||
pub fn remove_panel(&mut self, panel: Arc<dyn PanelView>, cx: &mut ViewContext<Self>) {
|
||||
if let Some(ix) = self.index_of_panel(panel.clone()) {
|
||||
self.panels.remove(ix);
|
||||
self.panel_group.update(cx, |view, cx| {
|
||||
view.remove_child(ix, cx);
|
||||
});
|
||||
|
||||
cx.emit(PanelEvent::LayoutChanged);
|
||||
self.remove_self_if_empty(cx);
|
||||
} else {
|
||||
println!("Panel not found in stack panel.");
|
||||
}
|
||||
}
|
||||
|
||||
/// Replace the old panel with the new panel at same index.
|
||||
pub(super) fn replace_panel(
|
||||
&mut self,
|
||||
old_panel: Arc<dyn PanelView>,
|
||||
new_panel: View<StackPanel>,
|
||||
cx: &mut ViewContext<Self>,
|
||||
) {
|
||||
if let Some(ix) = self.index_of_panel(old_panel.clone()) {
|
||||
self.panels[ix] = Arc::new(new_panel.clone());
|
||||
self.panel_group.update(cx, |view, cx| {
|
||||
view.replace_child(
|
||||
Self::new_resizable_panel(Arc::new(new_panel.clone()), None),
|
||||
ix,
|
||||
cx,
|
||||
);
|
||||
});
|
||||
cx.emit(PanelEvent::LayoutChanged);
|
||||
}
|
||||
}
|
||||
|
||||
/// If children is empty, remove self from parent view.
|
||||
pub(crate) fn remove_self_if_empty(&mut self, cx: &mut ViewContext<Self>) {
|
||||
if self.is_root() {
|
||||
return;
|
||||
}
|
||||
|
||||
if !self.panels.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let view = cx.view().clone();
|
||||
if let Some(parent) = self.parent.as_ref() {
|
||||
_ = parent.update(cx, |parent, cx| {
|
||||
parent.remove_panel(Arc::new(view.clone()), cx);
|
||||
});
|
||||
}
|
||||
|
||||
cx.emit(PanelEvent::LayoutChanged);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Find the first top left in the stack.
|
||||
pub(super) fn left_top_tab_panel(
|
||||
&self,
|
||||
check_parent: bool,
|
||||
cx: &AppContext,
|
||||
) -> Option<View<TabPanel>> {
|
||||
if check_parent {
|
||||
if let Some(parent) = self.parent.as_ref().and_then(|parent| parent.upgrade()) {
|
||||
if let Some(panel) = parent.read(cx).left_top_tab_panel(true, cx) {
|
||||
return Some(panel);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let first_panel = self.panels.first();
|
||||
if let Some(view) = first_panel {
|
||||
if let Ok(tab_panel) = view.view().downcast::<TabPanel>() {
|
||||
Some(tab_panel)
|
||||
} else if let Ok(stack_panel) = view.view().downcast::<StackPanel>() {
|
||||
stack_panel.read(cx).left_top_tab_panel(false, cx)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Find the first top right in the stack.
|
||||
pub(super) fn right_top_tab_panel(
|
||||
&self,
|
||||
check_parent: bool,
|
||||
cx: &AppContext,
|
||||
) -> Option<View<TabPanel>> {
|
||||
if check_parent {
|
||||
if let Some(parent) = self.parent.as_ref().and_then(|parent| parent.upgrade()) {
|
||||
if let Some(panel) = parent.read(cx).right_top_tab_panel(true, cx) {
|
||||
return Some(panel);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let panel = if self.axis.is_vertical() {
|
||||
self.panels.first()
|
||||
} else {
|
||||
self.panels.last()
|
||||
};
|
||||
|
||||
if let Some(view) = panel {
|
||||
if let Ok(tab_panel) = view.view().downcast::<TabPanel>() {
|
||||
Some(tab_panel)
|
||||
} else if let Ok(stack_panel) = view.view().downcast::<StackPanel>() {
|
||||
stack_panel.read(cx).right_top_tab_panel(false, cx)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove all panels from the stack.
|
||||
pub(super) fn remove_all_panels(&mut self, cx: &mut ViewContext<Self>) {
|
||||
self.panels.clear();
|
||||
self.panel_group
|
||||
.update(cx, |view, cx| view.remove_all_children(cx));
|
||||
}
|
||||
|
||||
/// Change the axis of the stack panel.
|
||||
pub(super) fn set_axis(&mut self, axis: Axis, cx: &mut ViewContext<Self>) {
|
||||
self.axis = axis;
|
||||
self.panel_group
|
||||
.update(cx, |view, cx| view.set_axis(axis, cx));
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
impl FocusableView for StackPanel {
|
||||
fn focus_handle(&self, _cx: &AppContext) -> FocusHandle {
|
||||
self.focus_handle.clone()
|
||||
}
|
||||
}
|
||||
impl EventEmitter<PanelEvent> for StackPanel {}
|
||||
impl EventEmitter<DismissEvent> for StackPanel {}
|
||||
impl Render for StackPanel {
|
||||
fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
|
||||
h_flex()
|
||||
.size_full()
|
||||
.overflow_hidden()
|
||||
.bg(cx.theme().tab_bar)
|
||||
.child(self.panel_group.clone())
|
||||
}
|
||||
}
|
||||
201
crates/ui/src/dock/state.rs
Normal file
201
crates/ui/src/dock/state.rs
Normal file
@@ -0,0 +1,201 @@
|
||||
use gpui::{AppContext, Axis, Pixels, View, VisualContext as _, WeakView, WindowContext};
|
||||
use itertools::Itertools as _;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::{
|
||||
invalid_panel::InvalidPanel, Dock, DockArea, DockItem, DockPlacement, Panel, PanelRegistry,
|
||||
};
|
||||
|
||||
/// Used to serialize and deserialize the DockArea
|
||||
#[derive(Debug, Default, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct DockAreaState {
|
||||
/// The version is used to mark this persisted state is compatible with the current version
|
||||
/// For example, some times we many totally changed the structure of the Panel,
|
||||
/// then we can compare the version to decide whether we can use the state or ignore.
|
||||
#[serde(default)]
|
||||
pub version: Option<usize>,
|
||||
pub center: DockItemState,
|
||||
pub left_dock: Option<DockState>,
|
||||
pub right_dock: Option<DockState>,
|
||||
pub bottom_dock: Option<DockState>,
|
||||
}
|
||||
|
||||
/// Used to serialize and deserialize the Dock
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct DockState {
|
||||
panel: DockItemState,
|
||||
placement: DockPlacement,
|
||||
size: Pixels,
|
||||
open: bool,
|
||||
}
|
||||
|
||||
impl DockState {
|
||||
pub fn new(dock: View<Dock>, cx: &AppContext) -> Self {
|
||||
let dock = dock.read(cx);
|
||||
|
||||
Self {
|
||||
placement: dock.placement,
|
||||
size: dock.size,
|
||||
open: dock.open,
|
||||
panel: dock.panel.view().dump(cx),
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert the DockState to Dock
|
||||
pub fn to_dock(&self, dock_area: WeakView<DockArea>, cx: &mut WindowContext) -> View<Dock> {
|
||||
let item = self.panel.to_item(dock_area.clone(), cx);
|
||||
cx.new_view(|cx| {
|
||||
Dock::from_state(
|
||||
dock_area.clone(),
|
||||
self.placement,
|
||||
self.size,
|
||||
item,
|
||||
self.open,
|
||||
cx,
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Used to serialize and deserialize the DockerItem
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct DockItemState {
|
||||
pub panel_name: String,
|
||||
pub children: Vec<DockItemState>,
|
||||
pub info: DockItemInfo,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub enum DockItemInfo {
|
||||
#[serde(rename = "stack")]
|
||||
Stack {
|
||||
sizes: Vec<Pixels>,
|
||||
/// The axis of the stack, 0 is horizontal, 1 is vertical
|
||||
axis: usize,
|
||||
},
|
||||
#[serde(rename = "tabs")]
|
||||
Tabs { active_index: usize },
|
||||
#[serde(rename = "panel")]
|
||||
Panel(serde_json::Value),
|
||||
}
|
||||
|
||||
impl DockItemInfo {
|
||||
pub fn stack(sizes: Vec<Pixels>, axis: Axis) -> Self {
|
||||
Self::Stack {
|
||||
sizes,
|
||||
axis: if axis == Axis::Horizontal { 0 } else { 1 },
|
||||
}
|
||||
}
|
||||
|
||||
pub fn tabs(active_index: usize) -> Self {
|
||||
Self::Tabs { active_index }
|
||||
}
|
||||
|
||||
pub fn panel(value: serde_json::Value) -> Self {
|
||||
Self::Panel(value)
|
||||
}
|
||||
|
||||
pub fn axis(&self) -> Option<Axis> {
|
||||
match self {
|
||||
Self::Stack { axis, .. } => Some(if *axis == 0 {
|
||||
Axis::Horizontal
|
||||
} else {
|
||||
Axis::Vertical
|
||||
}),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn sizes(&self) -> Option<&Vec<Pixels>> {
|
||||
match self {
|
||||
Self::Stack { sizes, .. } => Some(sizes),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn active_index(&self) -> Option<usize> {
|
||||
match self {
|
||||
Self::Tabs { active_index } => Some(*active_index),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for DockItemState {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
panel_name: "".to_string(),
|
||||
children: Vec::new(),
|
||||
info: DockItemInfo::Panel(serde_json::Value::Null),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl DockItemState {
|
||||
pub fn new<P: Panel>(panel: &P) -> Self {
|
||||
Self {
|
||||
panel_name: panel.panel_name().to_string(),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn add_child(&mut self, panel: DockItemState) {
|
||||
self.children.push(panel);
|
||||
}
|
||||
|
||||
pub fn to_item(&self, dock_area: WeakView<DockArea>, cx: &mut WindowContext) -> DockItem {
|
||||
let info = self.info.clone();
|
||||
|
||||
let items: Vec<DockItem> = self
|
||||
.children
|
||||
.iter()
|
||||
.map(|child| child.to_item(dock_area.clone(), cx))
|
||||
.collect();
|
||||
|
||||
match info {
|
||||
DockItemInfo::Stack { sizes, axis } => {
|
||||
let axis = if axis == 0 {
|
||||
Axis::Horizontal
|
||||
} else {
|
||||
Axis::Vertical
|
||||
};
|
||||
let sizes = sizes.iter().map(|s| Some(*s)).collect_vec();
|
||||
DockItem::split_with_sizes(axis, items, sizes, &dock_area, cx)
|
||||
}
|
||||
DockItemInfo::Tabs { active_index } => {
|
||||
if items.len() == 1 {
|
||||
return items[0].clone();
|
||||
}
|
||||
|
||||
let items = items
|
||||
.iter()
|
||||
.flat_map(|item| match item {
|
||||
DockItem::Tabs { items, .. } => items.clone(),
|
||||
_ => {
|
||||
unreachable!("Invalid DockItem type in DockItemInfo::Tabs")
|
||||
}
|
||||
})
|
||||
.collect_vec();
|
||||
|
||||
DockItem::tabs(items, Some(active_index), &dock_area, cx)
|
||||
}
|
||||
DockItemInfo::Panel(_) => {
|
||||
let view = if let Some(f) = cx
|
||||
.global::<PanelRegistry>()
|
||||
.items
|
||||
.get(&self.panel_name)
|
||||
.cloned()
|
||||
{
|
||||
f(dock_area.clone(), self, &info, cx)
|
||||
} else {
|
||||
// Show an invalid panel if the panel is not registered.
|
||||
Box::new(
|
||||
cx.new_view(|cx| InvalidPanel::new(&self.panel_name, self.clone(), cx)),
|
||||
)
|
||||
};
|
||||
|
||||
DockItem::tabs(vec![view.into()], None, &dock_area, cx)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
888
crates/ui/src/dock/tab_panel.rs
Normal file
888
crates/ui/src/dock/tab_panel.rs
Normal file
@@ -0,0 +1,888 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use gpui::{
|
||||
div, prelude::FluentBuilder, px, rems, AnchorCorner, AppContext, DefiniteLength, DismissEvent,
|
||||
DragMoveEvent, Empty, Entity, EventEmitter, FocusHandle, FocusableView,
|
||||
InteractiveElement as _, IntoElement, ParentElement, Pixels, Render, ScrollHandle,
|
||||
SharedString, StatefulInteractiveElement, Styled, View, ViewContext, VisualContext as _,
|
||||
WeakView, WindowContext,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
button::{Button, ButtonVariants as _},
|
||||
dock::DockItemInfo,
|
||||
h_flex,
|
||||
popup_menu::{PopupMenu, PopupMenuExt},
|
||||
tab::{Tab, TabBar},
|
||||
theme::ActiveTheme,
|
||||
v_flex, AxisExt, IconName, Placement, Selectable, Sizable,
|
||||
};
|
||||
|
||||
use super::{
|
||||
ClosePanel, DockArea, DockItemState, DockPlacement, Panel, PanelEvent, PanelStyle, PanelView,
|
||||
StackPanel, ToggleZoom,
|
||||
};
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct TabState {
|
||||
closeable: bool,
|
||||
zoomable: bool,
|
||||
draggable: bool,
|
||||
droppable: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct DragPanel {
|
||||
pub(crate) panel: Arc<dyn PanelView>,
|
||||
pub(crate) tab_panel: View<TabPanel>,
|
||||
}
|
||||
|
||||
impl DragPanel {
|
||||
pub(crate) fn new(panel: Arc<dyn PanelView>, tab_panel: View<TabPanel>) -> Self {
|
||||
Self { panel, tab_panel }
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for DragPanel {
|
||||
fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
|
||||
div()
|
||||
.id("drag-panel")
|
||||
.cursor_grab()
|
||||
.py_1()
|
||||
.px_3()
|
||||
.w_24()
|
||||
.overflow_hidden()
|
||||
.whitespace_nowrap()
|
||||
.border_1()
|
||||
.border_color(cx.theme().border)
|
||||
.rounded_md()
|
||||
.text_color(cx.theme().tab_foreground)
|
||||
.bg(cx.theme().tab_active)
|
||||
.opacity(0.75)
|
||||
.child(self.panel.title(cx))
|
||||
}
|
||||
}
|
||||
|
||||
pub struct TabPanel {
|
||||
focus_handle: FocusHandle,
|
||||
dock_area: WeakView<DockArea>,
|
||||
/// The stock_panel can be None, if is None, that means the panels can't be split or move
|
||||
stack_panel: Option<WeakView<StackPanel>>,
|
||||
pub(crate) panels: Vec<Arc<dyn PanelView>>,
|
||||
pub(crate) active_ix: usize,
|
||||
/// If this is true, the Panel closeable will follow the active panel's closeable,
|
||||
/// otherwise this TabPanel will not able to close
|
||||
pub(crate) closeable: bool,
|
||||
|
||||
tab_bar_scroll_handle: ScrollHandle,
|
||||
is_zoomed: bool,
|
||||
is_collapsed: bool,
|
||||
/// When drag move, will get the placement of the panel to be split
|
||||
will_split_placement: Option<Placement>,
|
||||
}
|
||||
|
||||
impl Panel for TabPanel {
|
||||
fn panel_name(&self) -> &'static str {
|
||||
"TabPanel"
|
||||
}
|
||||
|
||||
fn title(&self, cx: &WindowContext) -> gpui::AnyElement {
|
||||
self.active_panel()
|
||||
.map(|panel| panel.title(cx))
|
||||
.unwrap_or("Empty Tab".into_any_element())
|
||||
}
|
||||
|
||||
fn closeable(&self, cx: &WindowContext) -> bool {
|
||||
if !self.closeable {
|
||||
return false;
|
||||
}
|
||||
|
||||
self.active_panel()
|
||||
.map(|panel| panel.closeable(cx))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn zoomable(&self, cx: &WindowContext) -> bool {
|
||||
self.active_panel()
|
||||
.map(|panel| panel.zoomable(cx))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn popup_menu(&self, menu: PopupMenu, cx: &WindowContext) -> PopupMenu {
|
||||
if let Some(panel) = self.active_panel() {
|
||||
panel.popup_menu(menu, cx)
|
||||
} else {
|
||||
menu
|
||||
}
|
||||
}
|
||||
|
||||
fn toolbar_buttons(&self, cx: &WindowContext) -> Vec<Button> {
|
||||
if let Some(panel) = self.active_panel() {
|
||||
panel.toolbar_buttons(cx)
|
||||
} else {
|
||||
vec![]
|
||||
}
|
||||
}
|
||||
|
||||
fn dump(&self, cx: &AppContext) -> DockItemState {
|
||||
let mut state = DockItemState::new(self);
|
||||
for panel in self.panels.iter() {
|
||||
state.add_child(panel.dump(cx));
|
||||
state.info = DockItemInfo::tabs(self.active_ix);
|
||||
}
|
||||
state
|
||||
}
|
||||
}
|
||||
|
||||
impl TabPanel {
|
||||
pub fn new(
|
||||
stack_panel: Option<WeakView<StackPanel>>,
|
||||
dock_area: WeakView<DockArea>,
|
||||
cx: &mut ViewContext<Self>,
|
||||
) -> Self {
|
||||
Self {
|
||||
focus_handle: cx.focus_handle(),
|
||||
dock_area,
|
||||
stack_panel,
|
||||
panels: Vec::new(),
|
||||
active_ix: 0,
|
||||
tab_bar_scroll_handle: ScrollHandle::new(),
|
||||
will_split_placement: None,
|
||||
is_zoomed: false,
|
||||
is_collapsed: false,
|
||||
closeable: true,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn set_parent(&mut self, view: WeakView<StackPanel>) {
|
||||
self.stack_panel = Some(view);
|
||||
}
|
||||
|
||||
/// Return current active_panel View
|
||||
pub fn active_panel(&self) -> Option<Arc<dyn PanelView>> {
|
||||
self.panels.get(self.active_ix).cloned()
|
||||
}
|
||||
|
||||
fn set_active_ix(&mut self, ix: usize, cx: &mut ViewContext<Self>) {
|
||||
self.active_ix = ix;
|
||||
self.tab_bar_scroll_handle.scroll_to_item(ix);
|
||||
self.focus_active_panel(cx);
|
||||
cx.emit(PanelEvent::LayoutChanged);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Add a panel to the end of the tabs
|
||||
pub fn add_panel(&mut self, panel: Arc<dyn PanelView>, cx: &mut ViewContext<Self>) {
|
||||
assert_ne!(
|
||||
panel.panel_name(cx),
|
||||
"StackPanel",
|
||||
"can not allows add `StackPanel` to `TabPanel`"
|
||||
);
|
||||
|
||||
if self
|
||||
.panels
|
||||
.iter()
|
||||
.any(|p| p.view().entity_id() == panel.view().entity_id())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
self.panels.push(panel);
|
||||
// set the active panel to the new panel
|
||||
self.set_active_ix(self.panels.len() - 1, cx);
|
||||
cx.emit(PanelEvent::LayoutChanged);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Add panel to try to split
|
||||
pub fn add_panel_at(
|
||||
&mut self,
|
||||
panel: Arc<dyn PanelView>,
|
||||
placement: Placement,
|
||||
size: Option<Pixels>,
|
||||
cx: &mut ViewContext<Self>,
|
||||
) {
|
||||
cx.spawn(|view, mut cx| async move {
|
||||
cx.update(|cx| {
|
||||
view.update(cx, |view, cx| {
|
||||
view.will_split_placement = Some(placement);
|
||||
view.split_panel(panel, placement, size, cx)
|
||||
})
|
||||
.ok()
|
||||
})
|
||||
.ok()
|
||||
})
|
||||
.detach();
|
||||
cx.emit(PanelEvent::LayoutChanged);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn insert_panel_at(
|
||||
&mut self,
|
||||
panel: Arc<dyn PanelView>,
|
||||
ix: usize,
|
||||
cx: &mut ViewContext<Self>,
|
||||
) {
|
||||
if self
|
||||
.panels
|
||||
.iter()
|
||||
.any(|p| p.view().entity_id() == panel.view().entity_id())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
self.panels.insert(ix, panel);
|
||||
self.set_active_ix(ix, cx);
|
||||
cx.emit(PanelEvent::LayoutChanged);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Remove a panel from the tab panel
|
||||
pub fn remove_panel(&mut self, panel: Arc<dyn PanelView>, cx: &mut ViewContext<Self>) {
|
||||
self.detach_panel(panel, cx);
|
||||
self.remove_self_if_empty(cx);
|
||||
cx.emit(PanelEvent::ZoomOut);
|
||||
cx.emit(PanelEvent::LayoutChanged);
|
||||
}
|
||||
|
||||
fn detach_panel(&mut self, panel: Arc<dyn PanelView>, cx: &mut ViewContext<Self>) {
|
||||
let panel_view = panel.view();
|
||||
self.panels.retain(|p| p.view() != panel_view);
|
||||
if self.active_ix >= self.panels.len() {
|
||||
self.set_active_ix(self.panels.len().saturating_sub(1), cx)
|
||||
}
|
||||
}
|
||||
|
||||
/// Check to remove self from the parent StackPanel, if there is no panel left
|
||||
fn remove_self_if_empty(&self, cx: &mut ViewContext<Self>) {
|
||||
if !self.panels.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let tab_view = cx.view().clone();
|
||||
if let Some(stack_panel) = self.stack_panel.as_ref() {
|
||||
_ = stack_panel.update(cx, |view, cx| {
|
||||
view.remove_panel(Arc::new(tab_view), cx);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn set_collapsed(&mut self, collapsed: bool, cx: &mut ViewContext<Self>) {
|
||||
self.is_collapsed = collapsed;
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn is_locked(&self, cx: &AppContext) -> bool {
|
||||
let Some(dock_area) = self.dock_area.upgrade() else {
|
||||
return true;
|
||||
};
|
||||
|
||||
if dock_area.read(cx).is_locked() {
|
||||
return true;
|
||||
}
|
||||
|
||||
if self.is_zoomed {
|
||||
return true;
|
||||
}
|
||||
|
||||
self.stack_panel.is_none()
|
||||
}
|
||||
|
||||
/// Return true if self or parent only have last panel.
|
||||
fn is_last_panel(&self, cx: &AppContext) -> bool {
|
||||
if let Some(parent) = &self.stack_panel {
|
||||
if let Some(stack_panel) = parent.upgrade() {
|
||||
if !stack_panel.read(cx).is_last_panel(cx) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self.panels.len() <= 1
|
||||
}
|
||||
|
||||
/// Return true if the tab panel is draggable.
|
||||
///
|
||||
/// E.g. if the parent and self only have one panel, it is not draggable.
|
||||
fn draggable(&self, cx: &AppContext) -> bool {
|
||||
!self.is_locked(cx) && !self.is_last_panel(cx)
|
||||
}
|
||||
|
||||
/// Return true if the tab panel is droppable.
|
||||
///
|
||||
/// E.g. if the tab panel is locked, it is not droppable.
|
||||
fn droppable(&self, cx: &AppContext) -> bool {
|
||||
!self.is_locked(cx)
|
||||
}
|
||||
|
||||
fn render_toolbar(&self, state: TabState, cx: &mut ViewContext<Self>) -> impl IntoElement {
|
||||
let is_zoomed = self.is_zoomed && state.zoomable;
|
||||
let view = cx.view().clone();
|
||||
let build_popup_menu = move |this, cx: &WindowContext| view.read(cx).popup_menu(this, cx);
|
||||
|
||||
// TODO: Do not show MenuButton if there is no menu items
|
||||
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.occlude()
|
||||
.items_center()
|
||||
.children(
|
||||
self.toolbar_buttons(cx)
|
||||
.into_iter()
|
||||
.map(|btn| btn.xsmall().ghost()),
|
||||
)
|
||||
.when(self.is_zoomed, |this| {
|
||||
this.child(
|
||||
Button::new("zoom")
|
||||
.icon(IconName::Minimize)
|
||||
.xsmall()
|
||||
.ghost()
|
||||
.tooltip("Zoom Out")
|
||||
.on_click(
|
||||
cx.listener(|view, _, cx| view.on_action_toggle_zoom(&ToggleZoom, cx)),
|
||||
),
|
||||
)
|
||||
})
|
||||
.child(
|
||||
Button::new("menu")
|
||||
.icon(IconName::Ellipsis)
|
||||
.xsmall()
|
||||
.ghost()
|
||||
.popup_menu(move |this, cx| {
|
||||
build_popup_menu(this, cx)
|
||||
.when(state.zoomable, |this| {
|
||||
let name = if is_zoomed { "Zoom Out" } else { "Zoom In" };
|
||||
this.separator().menu(name, Box::new(ToggleZoom))
|
||||
})
|
||||
.when(state.closeable, |this| {
|
||||
this.separator().menu("Close", Box::new(ClosePanel))
|
||||
})
|
||||
})
|
||||
.anchor(AnchorCorner::TopRight),
|
||||
)
|
||||
}
|
||||
|
||||
fn render_dock_toggle_button(
|
||||
&self,
|
||||
placement: DockPlacement,
|
||||
cx: &mut ViewContext<Self>,
|
||||
) -> Option<impl IntoElement> {
|
||||
if self.is_zoomed {
|
||||
return None;
|
||||
}
|
||||
|
||||
let dock_area = self.dock_area.upgrade()?.read(cx);
|
||||
if !dock_area.is_dock_collapsible(placement, cx) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let view_entity_id = cx.view().entity_id();
|
||||
let toggle_button_panels = dock_area.toggle_button_panels;
|
||||
|
||||
// Check if current TabPanel's entity_id matches the one stored in DockArea for this placement
|
||||
if !match placement {
|
||||
DockPlacement::Left => {
|
||||
dock_area.left_dock.is_some() && toggle_button_panels.left == Some(view_entity_id)
|
||||
}
|
||||
DockPlacement::Right => {
|
||||
dock_area.right_dock.is_some() && toggle_button_panels.right == Some(view_entity_id)
|
||||
}
|
||||
DockPlacement::Bottom => {
|
||||
dock_area.bottom_dock.is_some()
|
||||
&& toggle_button_panels.bottom == Some(view_entity_id)
|
||||
}
|
||||
DockPlacement::Center => unreachable!(),
|
||||
} {
|
||||
return None;
|
||||
}
|
||||
|
||||
let is_open = dock_area.is_dock_open(placement, cx);
|
||||
|
||||
let icon = match placement {
|
||||
DockPlacement::Left => {
|
||||
if is_open {
|
||||
IconName::PanelLeft
|
||||
} else {
|
||||
IconName::PanelLeftOpen
|
||||
}
|
||||
}
|
||||
DockPlacement::Right => {
|
||||
if is_open {
|
||||
IconName::PanelRight
|
||||
} else {
|
||||
IconName::PanelRightOpen
|
||||
}
|
||||
}
|
||||
DockPlacement::Bottom => {
|
||||
if is_open {
|
||||
IconName::PanelBottom
|
||||
} else {
|
||||
IconName::PanelBottomOpen
|
||||
}
|
||||
}
|
||||
DockPlacement::Center => unreachable!(),
|
||||
};
|
||||
|
||||
Some(
|
||||
Button::new(SharedString::from(format!("toggle-dock:{:?}", placement)))
|
||||
.icon(icon)
|
||||
.xsmall()
|
||||
.ghost()
|
||||
.tooltip(match is_open {
|
||||
true => "Collapse",
|
||||
false => "Expand",
|
||||
})
|
||||
.on_click(cx.listener({
|
||||
let dock_area = self.dock_area.clone();
|
||||
move |_, _, cx| {
|
||||
_ = dock_area.update(cx, |dock_area, cx| {
|
||||
dock_area.toggle_dock(placement, cx);
|
||||
});
|
||||
}
|
||||
})),
|
||||
)
|
||||
}
|
||||
|
||||
fn render_title_bar(&self, state: TabState, cx: &mut ViewContext<Self>) -> impl IntoElement {
|
||||
let view = cx.view().clone();
|
||||
|
||||
let Some(dock_area) = self.dock_area.upgrade() else {
|
||||
return div().into_any_element();
|
||||
};
|
||||
let panel_style = dock_area.read(cx).panel_style;
|
||||
|
||||
let left_dock_button = self.render_dock_toggle_button(DockPlacement::Left, cx);
|
||||
let bottom_dock_button = self.render_dock_toggle_button(DockPlacement::Bottom, cx);
|
||||
let right_dock_button = self.render_dock_toggle_button(DockPlacement::Right, cx);
|
||||
|
||||
if self.panels.len() == 1 && panel_style == PanelStyle::Default {
|
||||
let panel = self.panels.get(0).unwrap();
|
||||
let title_style = panel.title_style(cx);
|
||||
|
||||
return h_flex()
|
||||
.justify_between()
|
||||
.items_center()
|
||||
.line_height(rems(1.0))
|
||||
.h(px(30.))
|
||||
.py_2()
|
||||
.px_3()
|
||||
.when(left_dock_button.is_some(), |this| this.pl_2())
|
||||
.when(right_dock_button.is_some(), |this| this.pr_2())
|
||||
.when_some(title_style, |this, theme| {
|
||||
this.bg(theme.background).text_color(theme.foreground)
|
||||
})
|
||||
.when(
|
||||
left_dock_button.is_some() || bottom_dock_button.is_some(),
|
||||
|this| {
|
||||
this.child(
|
||||
h_flex()
|
||||
.flex_shrink_0()
|
||||
.mr_1()
|
||||
.gap_1()
|
||||
.children(left_dock_button)
|
||||
.children(bottom_dock_button),
|
||||
)
|
||||
},
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.id("tab")
|
||||
.flex_1()
|
||||
.min_w_16()
|
||||
.overflow_hidden()
|
||||
.text_ellipsis()
|
||||
.whitespace_nowrap()
|
||||
.child(panel.title(cx))
|
||||
.when(state.draggable, |this| {
|
||||
this.on_drag(
|
||||
DragPanel {
|
||||
panel: panel.clone(),
|
||||
tab_panel: view,
|
||||
},
|
||||
|drag, _, cx| {
|
||||
cx.stop_propagation();
|
||||
cx.new_view(|_| drag.clone())
|
||||
},
|
||||
)
|
||||
}),
|
||||
)
|
||||
.child(
|
||||
h_flex()
|
||||
.flex_shrink_0()
|
||||
.ml_1()
|
||||
.gap_1()
|
||||
.child(self.render_toolbar(state, cx))
|
||||
.children(right_dock_button),
|
||||
)
|
||||
.into_any_element();
|
||||
}
|
||||
|
||||
let tabs_count = self.panels.len();
|
||||
|
||||
TabBar::new("tab-bar")
|
||||
.track_scroll(self.tab_bar_scroll_handle.clone())
|
||||
.when(
|
||||
left_dock_button.is_some() || bottom_dock_button.is_some(),
|
||||
|this| {
|
||||
this.prefix(
|
||||
h_flex()
|
||||
.items_center()
|
||||
.top_0()
|
||||
// Right -1 for avoid border overlap with the first tab
|
||||
.right(-px(1.))
|
||||
.border_r_1()
|
||||
.border_b_1()
|
||||
.h_full()
|
||||
.border_color(cx.theme().border)
|
||||
.bg(cx.theme().tab_bar)
|
||||
.px_2()
|
||||
.children(left_dock_button)
|
||||
.children(bottom_dock_button),
|
||||
)
|
||||
},
|
||||
)
|
||||
.children(self.panels.iter().enumerate().map(|(ix, panel)| {
|
||||
let mut active = ix == self.active_ix;
|
||||
|
||||
// Always not show active tab style, if the panel is collapsed
|
||||
if self.is_collapsed {
|
||||
active = false;
|
||||
}
|
||||
|
||||
Tab::new(("tab", ix), panel.title(cx))
|
||||
.py_2()
|
||||
.selected(active)
|
||||
.on_click(cx.listener(move |view, _, cx| {
|
||||
view.set_active_ix(ix, cx);
|
||||
}))
|
||||
.when(state.draggable, |this| {
|
||||
this.on_drag(
|
||||
DragPanel::new(panel.clone(), view.clone()),
|
||||
|drag, _, cx| {
|
||||
cx.stop_propagation();
|
||||
cx.new_view(|_| drag.clone())
|
||||
},
|
||||
)
|
||||
})
|
||||
.when(state.droppable, |this| {
|
||||
this.drag_over::<DragPanel>(|this, _, cx| {
|
||||
this.rounded_l_none()
|
||||
.border_l_2()
|
||||
.border_r_0()
|
||||
.border_color(cx.theme().drag_border)
|
||||
})
|
||||
.on_drop(cx.listener(
|
||||
move |this, drag: &DragPanel, cx| {
|
||||
this.will_split_placement = None;
|
||||
this.on_drop(drag, Some(ix), cx)
|
||||
},
|
||||
))
|
||||
})
|
||||
}))
|
||||
.child(
|
||||
// empty space to allow move to last tab right
|
||||
div()
|
||||
.id("tab-bar-empty-space")
|
||||
.h_full()
|
||||
.flex_grow()
|
||||
.min_w_16()
|
||||
.when(state.droppable, |this| {
|
||||
this.drag_over::<DragPanel>(|this, _, cx| this.bg(cx.theme().drop_target))
|
||||
.on_drop(cx.listener(move |this, drag: &DragPanel, cx| {
|
||||
this.will_split_placement = None;
|
||||
|
||||
let ix = if drag.tab_panel == view {
|
||||
Some(tabs_count - 1)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
this.on_drop(drag, ix, cx)
|
||||
}))
|
||||
}),
|
||||
)
|
||||
.suffix(
|
||||
h_flex()
|
||||
.items_center()
|
||||
.top_0()
|
||||
.right_0()
|
||||
.border_l_1()
|
||||
.border_b_1()
|
||||
.h_full()
|
||||
.border_color(cx.theme().border)
|
||||
.bg(cx.theme().tab_bar)
|
||||
.px_2()
|
||||
.gap_1()
|
||||
.child(self.render_toolbar(state, cx))
|
||||
.when_some(right_dock_button, |this, btn| this.child(btn)),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn render_active_panel(&self, state: TabState, cx: &mut ViewContext<Self>) -> impl IntoElement {
|
||||
self.active_panel()
|
||||
.map(|panel| {
|
||||
div()
|
||||
.id("tab-content")
|
||||
.group("")
|
||||
.overflow_y_scroll()
|
||||
.overflow_x_hidden()
|
||||
.flex_1()
|
||||
.child(panel.view())
|
||||
.when(state.droppable, |this| {
|
||||
this.on_drag_move(cx.listener(Self::on_panel_drag_move))
|
||||
.child(
|
||||
div()
|
||||
.invisible()
|
||||
.absolute()
|
||||
.bg(cx.theme().drop_target)
|
||||
.map(|this| match self.will_split_placement {
|
||||
Some(placement) => {
|
||||
let size = DefiniteLength::Fraction(0.35);
|
||||
match placement {
|
||||
Placement::Left => {
|
||||
this.left_0().top_0().bottom_0().w(size)
|
||||
}
|
||||
Placement::Right => {
|
||||
this.right_0().top_0().bottom_0().w(size)
|
||||
}
|
||||
Placement::Top => {
|
||||
this.top_0().left_0().right_0().h(size)
|
||||
}
|
||||
Placement::Bottom => {
|
||||
this.bottom_0().left_0().right_0().h(size)
|
||||
}
|
||||
}
|
||||
}
|
||||
None => this.top_0().left_0().size_full(),
|
||||
})
|
||||
.group_drag_over::<DragPanel>("", |this| this.visible())
|
||||
.on_drop(cx.listener(|this, drag: &DragPanel, cx| {
|
||||
this.on_drop(drag, None, cx)
|
||||
})),
|
||||
)
|
||||
})
|
||||
.into_any_element()
|
||||
})
|
||||
.unwrap_or(Empty {}.into_any_element())
|
||||
}
|
||||
|
||||
/// Calculate the split direction based on the current mouse position
|
||||
fn on_panel_drag_move(&mut self, drag: &DragMoveEvent<DragPanel>, cx: &mut ViewContext<Self>) {
|
||||
let bounds = drag.bounds;
|
||||
let position = drag.event.position;
|
||||
|
||||
// Check the mouse position to determine the split direction
|
||||
if position.x < bounds.left() + bounds.size.width * 0.35 {
|
||||
self.will_split_placement = Some(Placement::Left);
|
||||
} else if position.x > bounds.left() + bounds.size.width * 0.65 {
|
||||
self.will_split_placement = Some(Placement::Right);
|
||||
} else if position.y < bounds.top() + bounds.size.height * 0.35 {
|
||||
self.will_split_placement = Some(Placement::Top);
|
||||
} else if position.y > bounds.top() + bounds.size.height * 0.65 {
|
||||
self.will_split_placement = Some(Placement::Bottom);
|
||||
} else {
|
||||
// center to merge into the current tab
|
||||
self.will_split_placement = None;
|
||||
}
|
||||
cx.notify()
|
||||
}
|
||||
|
||||
fn on_drop(&mut self, drag: &DragPanel, ix: Option<usize>, cx: &mut ViewContext<Self>) {
|
||||
let panel = drag.panel.clone();
|
||||
let is_same_tab = drag.tab_panel == *cx.view();
|
||||
|
||||
// If target is same tab, and it is only one panel, do nothing.
|
||||
if is_same_tab && ix.is_none() {
|
||||
if self.will_split_placement.is_none() {
|
||||
return;
|
||||
} else if self.panels.len() == 1 {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Here is looks like remove_panel on a same item, but it difference.
|
||||
//
|
||||
// We must to split it to remove_panel, unless it will be crash by error:
|
||||
// Cannot update ui::dock::tab_panel::TabPanel while it is already being updated
|
||||
if is_same_tab {
|
||||
self.detach_panel(panel.clone(), cx);
|
||||
} else {
|
||||
let _ = drag.tab_panel.update(cx, |view, cx| {
|
||||
view.detach_panel(panel.clone(), cx);
|
||||
view.remove_self_if_empty(cx);
|
||||
});
|
||||
}
|
||||
|
||||
// Insert into new tabs
|
||||
if let Some(placement) = self.will_split_placement {
|
||||
self.split_panel(panel, placement, None, cx);
|
||||
} else if let Some(ix) = ix {
|
||||
self.insert_panel_at(panel, ix, cx)
|
||||
} else {
|
||||
self.add_panel(panel, cx)
|
||||
}
|
||||
|
||||
self.remove_self_if_empty(cx);
|
||||
cx.emit(PanelEvent::LayoutChanged);
|
||||
}
|
||||
|
||||
/// Add panel with split placement
|
||||
fn split_panel(
|
||||
&self,
|
||||
panel: Arc<dyn PanelView>,
|
||||
placement: Placement,
|
||||
size: Option<Pixels>,
|
||||
cx: &mut ViewContext<Self>,
|
||||
) {
|
||||
let dock_area = self.dock_area.clone();
|
||||
// wrap the panel in a TabPanel
|
||||
let new_tab_panel = cx.new_view(|cx| Self::new(None, dock_area.clone(), cx));
|
||||
new_tab_panel.update(cx, |view, cx| {
|
||||
view.add_panel(panel, cx);
|
||||
});
|
||||
|
||||
let stack_panel = match self.stack_panel.as_ref().and_then(|panel| panel.upgrade()) {
|
||||
Some(panel) => panel,
|
||||
None => return,
|
||||
};
|
||||
|
||||
let parent_axis = stack_panel.read(cx).axis;
|
||||
|
||||
let ix = stack_panel
|
||||
.read(cx)
|
||||
.index_of_panel(Arc::new(cx.view().clone()))
|
||||
.unwrap_or_default();
|
||||
|
||||
if parent_axis.is_vertical() && placement.is_vertical() {
|
||||
stack_panel.update(cx, |view, cx| {
|
||||
view.insert_panel_at(
|
||||
Arc::new(new_tab_panel),
|
||||
ix,
|
||||
placement,
|
||||
size,
|
||||
dock_area.clone(),
|
||||
cx,
|
||||
);
|
||||
});
|
||||
} else if parent_axis.is_horizontal() && placement.is_horizontal() {
|
||||
stack_panel.update(cx, |view, cx| {
|
||||
view.insert_panel_at(
|
||||
Arc::new(new_tab_panel),
|
||||
ix,
|
||||
placement,
|
||||
size,
|
||||
dock_area.clone(),
|
||||
cx,
|
||||
);
|
||||
});
|
||||
} else {
|
||||
// 1. Create new StackPanel with new axis
|
||||
// 2. Move cx.view() from parent StackPanel to the new StackPanel
|
||||
// 3. Add the new TabPanel to the new StackPanel at the correct index
|
||||
// 4. Add new StackPanel to the parent StackPanel at the correct index
|
||||
let tab_panel = cx.view().clone();
|
||||
|
||||
// Try to use the old stack panel, not just create a new one, to avoid too many nested stack panels
|
||||
let new_stack_panel = if stack_panel.read(cx).panels_len() <= 1 {
|
||||
stack_panel.update(cx, |view, cx| {
|
||||
view.remove_all_panels(cx);
|
||||
view.set_axis(placement.axis(), cx);
|
||||
});
|
||||
stack_panel.clone()
|
||||
} else {
|
||||
cx.new_view(|cx| {
|
||||
let mut panel = StackPanel::new(placement.axis(), cx);
|
||||
panel.parent = Some(stack_panel.downgrade());
|
||||
panel
|
||||
})
|
||||
};
|
||||
|
||||
new_stack_panel.update(cx, |view, cx| match placement {
|
||||
Placement::Left | Placement::Top => {
|
||||
view.add_panel(Arc::new(new_tab_panel), size, dock_area.clone(), cx);
|
||||
view.add_panel(Arc::new(tab_panel.clone()), None, dock_area.clone(), cx);
|
||||
}
|
||||
Placement::Right | Placement::Bottom => {
|
||||
view.add_panel(Arc::new(tab_panel.clone()), None, dock_area.clone(), cx);
|
||||
view.add_panel(Arc::new(new_tab_panel), size, dock_area.clone(), cx);
|
||||
}
|
||||
});
|
||||
|
||||
if stack_panel != new_stack_panel {
|
||||
stack_panel.update(cx, |view, cx| {
|
||||
view.replace_panel(Arc::new(tab_panel.clone()), new_stack_panel.clone(), cx);
|
||||
});
|
||||
}
|
||||
|
||||
cx.spawn(|_, mut cx| async move {
|
||||
cx.update(|cx| tab_panel.update(cx, |view, cx| view.remove_self_if_empty(cx)))
|
||||
})
|
||||
.detach()
|
||||
}
|
||||
|
||||
cx.emit(PanelEvent::LayoutChanged);
|
||||
}
|
||||
|
||||
fn focus_active_panel(&self, cx: &mut ViewContext<Self>) {
|
||||
if let Some(active_panel) = self.active_panel() {
|
||||
active_panel.focus_handle(cx).focus(cx);
|
||||
}
|
||||
}
|
||||
|
||||
fn on_action_toggle_zoom(&mut self, _: &ToggleZoom, cx: &mut ViewContext<Self>) {
|
||||
if !self.zoomable(cx) {
|
||||
return;
|
||||
}
|
||||
|
||||
if !self.is_zoomed {
|
||||
cx.emit(PanelEvent::ZoomIn)
|
||||
} else {
|
||||
cx.emit(PanelEvent::ZoomOut)
|
||||
}
|
||||
self.is_zoomed = !self.is_zoomed;
|
||||
}
|
||||
|
||||
fn on_action_close_panel(&mut self, _: &ClosePanel, cx: &mut ViewContext<Self>) {
|
||||
if let Some(panel) = self.active_panel() {
|
||||
self.remove_panel(panel, cx);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl FocusableView for TabPanel {
|
||||
fn focus_handle(&self, cx: &AppContext) -> gpui::FocusHandle {
|
||||
if let Some(active_panel) = self.active_panel() {
|
||||
active_panel.focus_handle(cx)
|
||||
} else {
|
||||
self.focus_handle.clone()
|
||||
}
|
||||
}
|
||||
}
|
||||
impl EventEmitter<DismissEvent> for TabPanel {}
|
||||
impl EventEmitter<PanelEvent> for TabPanel {}
|
||||
impl Render for TabPanel {
|
||||
fn render(&mut self, cx: &mut ViewContext<Self>) -> impl gpui::IntoElement {
|
||||
let focus_handle = self.focus_handle(cx);
|
||||
let mut state = TabState {
|
||||
closeable: self.closeable(cx),
|
||||
draggable: self.draggable(cx),
|
||||
droppable: self.droppable(cx),
|
||||
zoomable: self.zoomable(cx),
|
||||
};
|
||||
if !state.draggable {
|
||||
state.closeable = false;
|
||||
}
|
||||
|
||||
v_flex()
|
||||
.id("tab-panel")
|
||||
.track_focus(&focus_handle)
|
||||
.on_action(cx.listener(Self::on_action_toggle_zoom))
|
||||
.on_action(cx.listener(Self::on_action_close_panel))
|
||||
.size_full()
|
||||
.overflow_hidden()
|
||||
.bg(cx.theme().background)
|
||||
.child(self.render_title_bar(state, cx))
|
||||
.child(self.render_active_panel(state, cx))
|
||||
}
|
||||
}
|
||||
244
crates/ui/src/drawer.rs
Normal file
244
crates/ui/src/drawer.rs
Normal file
@@ -0,0 +1,244 @@
|
||||
use std::{rc::Rc, time::Duration};
|
||||
|
||||
use gpui::{
|
||||
actions, anchored, div, point, prelude::FluentBuilder as _, px, Animation, AnimationExt as _,
|
||||
AnyElement, AppContext, ClickEvent, DefiniteLength, DismissEvent, Div, EventEmitter,
|
||||
FocusHandle, InteractiveElement as _, IntoElement, KeyBinding, MouseButton, ParentElement,
|
||||
Pixels, RenderOnce, Styled, WindowContext,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
button::{Button, ButtonVariants as _},
|
||||
h_flex,
|
||||
modal::overlay_color,
|
||||
root::ContextModal as _,
|
||||
scroll::ScrollbarAxis,
|
||||
theme::ActiveTheme,
|
||||
title_bar::TITLE_BAR_HEIGHT,
|
||||
v_flex, IconName, Placement, Sizable, StyledExt as _,
|
||||
};
|
||||
|
||||
actions!(drawer, [Escape]);
|
||||
|
||||
const CONTEXT: &str = "Drawer";
|
||||
pub fn init(cx: &mut AppContext) {
|
||||
cx.bind_keys([KeyBinding::new("escape", Escape, Some(CONTEXT))])
|
||||
}
|
||||
|
||||
#[derive(IntoElement)]
|
||||
pub struct Drawer {
|
||||
pub(crate) focus_handle: FocusHandle,
|
||||
placement: Placement,
|
||||
size: DefiniteLength,
|
||||
resizable: bool,
|
||||
on_close: Rc<dyn Fn(&ClickEvent, &mut WindowContext) + 'static>,
|
||||
title: Option<AnyElement>,
|
||||
footer: Option<AnyElement>,
|
||||
content: Div,
|
||||
margin_top: Pixels,
|
||||
overlay: bool,
|
||||
}
|
||||
|
||||
impl Drawer {
|
||||
pub fn new(cx: &mut WindowContext) -> Self {
|
||||
Self {
|
||||
focus_handle: cx.focus_handle(),
|
||||
placement: Placement::Right,
|
||||
size: DefiniteLength::Absolute(px(350.).into()),
|
||||
resizable: true,
|
||||
title: None,
|
||||
footer: None,
|
||||
content: v_flex().px_4().py_3(),
|
||||
margin_top: TITLE_BAR_HEIGHT,
|
||||
overlay: true,
|
||||
on_close: Rc::new(|_, _| {}),
|
||||
}
|
||||
}
|
||||
|
||||
/// Sets the title of the drawer.
|
||||
pub fn title(mut self, title: impl IntoElement) -> Self {
|
||||
self.title = Some(title.into_any_element());
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the footer of the drawer.
|
||||
pub fn footer(mut self, footer: impl IntoElement) -> Self {
|
||||
self.footer = Some(footer.into_any_element());
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets the size of the drawer, default is 350px.
|
||||
pub fn size(mut self, size: impl Into<DefiniteLength>) -> Self {
|
||||
self.size = size.into();
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets the margin top of the drawer, default is 0px.
|
||||
///
|
||||
/// This is used to let Drawer be placed below a Windows Title, you can give the height of the title bar.
|
||||
pub fn margin_top(mut self, top: Pixels) -> Self {
|
||||
self.margin_top = top;
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets the placement of the drawer, default is `Placement::Right`.
|
||||
pub fn placement(mut self, placement: Placement) -> Self {
|
||||
self.placement = placement;
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets the placement of the drawer, default is `Placement::Right`.
|
||||
pub fn set_placement(&mut self, placement: Placement) {
|
||||
self.placement = placement;
|
||||
}
|
||||
|
||||
/// Sets whether the drawer is resizable, default is `true`.
|
||||
pub fn resizable(mut self, resizable: bool) -> Self {
|
||||
self.resizable = resizable;
|
||||
self
|
||||
}
|
||||
|
||||
/// Set whether the drawer should have an overlay, default is `true`.
|
||||
pub fn overlay(mut self, overlay: bool) -> Self {
|
||||
self.overlay = overlay;
|
||||
self
|
||||
}
|
||||
|
||||
/// Listen to the close event of the drawer.
|
||||
pub fn on_close(
|
||||
mut self,
|
||||
on_close: impl Fn(&ClickEvent, &mut WindowContext) + 'static,
|
||||
) -> Self {
|
||||
self.on_close = Rc::new(on_close);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl EventEmitter<DismissEvent> for Drawer {}
|
||||
impl ParentElement for Drawer {
|
||||
fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
|
||||
self.content.extend(elements);
|
||||
}
|
||||
}
|
||||
impl Styled for Drawer {
|
||||
fn style(&mut self) -> &mut gpui::StyleRefinement {
|
||||
self.content.style()
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for Drawer {
|
||||
fn render(self, cx: &mut WindowContext) -> impl IntoElement {
|
||||
let placement = self.placement;
|
||||
let titlebar_height = self.margin_top;
|
||||
let size = cx.viewport_size();
|
||||
let on_close = self.on_close.clone();
|
||||
|
||||
anchored()
|
||||
.position(point(px(0.), titlebar_height))
|
||||
.snap_to_window()
|
||||
.child(
|
||||
div()
|
||||
.occlude()
|
||||
.w(size.width)
|
||||
.h(size.height - titlebar_height)
|
||||
.bg(overlay_color(self.overlay, cx))
|
||||
.when(self.overlay, |this| {
|
||||
this.on_mouse_down(MouseButton::Left, {
|
||||
let on_close = self.on_close.clone();
|
||||
move |_, cx| {
|
||||
on_close(&ClickEvent::default(), cx);
|
||||
cx.close_drawer();
|
||||
}
|
||||
})
|
||||
})
|
||||
.child(
|
||||
v_flex()
|
||||
.id("drawer")
|
||||
.key_context(CONTEXT)
|
||||
.track_focus(&self.focus_handle)
|
||||
.on_action({
|
||||
let on_close = self.on_close.clone();
|
||||
move |_: &Escape, cx| {
|
||||
on_close(&ClickEvent::default(), cx);
|
||||
cx.close_drawer();
|
||||
}
|
||||
})
|
||||
.absolute()
|
||||
.occlude()
|
||||
.bg(cx.theme().background)
|
||||
.border_color(cx.theme().border)
|
||||
.shadow_xl()
|
||||
.map(|this| {
|
||||
// Set the size of the drawer.
|
||||
if placement.is_horizontal() {
|
||||
this.h_full().w(self.size)
|
||||
} else {
|
||||
this.w_full().h(self.size)
|
||||
}
|
||||
})
|
||||
.map(|this| match self.placement {
|
||||
Placement::Top => this.top_0().left_0().right_0().border_b_1(),
|
||||
Placement::Right => this.top_0().right_0().bottom_0().border_l_1(),
|
||||
Placement::Bottom => {
|
||||
this.bottom_0().left_0().right_0().border_t_1()
|
||||
}
|
||||
Placement::Left => this.top_0().left_0().bottom_0().border_r_1(),
|
||||
})
|
||||
.child(
|
||||
// TitleBar
|
||||
h_flex()
|
||||
.justify_between()
|
||||
.px_4()
|
||||
.py_2()
|
||||
.w_full()
|
||||
.child(self.title.unwrap_or(div().into_any_element()))
|
||||
.child(
|
||||
Button::new("close")
|
||||
.small()
|
||||
.ghost()
|
||||
.icon(IconName::Close)
|
||||
.on_click(move |_, cx| {
|
||||
on_close(&ClickEvent::default(), cx);
|
||||
cx.close_drawer();
|
||||
}),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
// Body
|
||||
div().flex_1().overflow_hidden().child(
|
||||
v_flex()
|
||||
.scrollable(
|
||||
cx.parent_view_id().unwrap_or_default(),
|
||||
ScrollbarAxis::Vertical,
|
||||
)
|
||||
.child(self.content),
|
||||
),
|
||||
)
|
||||
.when_some(self.footer, |this, footer| {
|
||||
// Footer
|
||||
this.child(
|
||||
h_flex()
|
||||
.justify_between()
|
||||
.px_4()
|
||||
.py_3()
|
||||
.w_full()
|
||||
.child(footer),
|
||||
)
|
||||
})
|
||||
.with_animation(
|
||||
"slide",
|
||||
Animation::new(Duration::from_secs_f64(0.15)),
|
||||
move |this, delta| {
|
||||
let y = px(-100.) + delta * px(100.);
|
||||
this.map(|this| match placement {
|
||||
Placement::Top => this.top(y),
|
||||
Placement::Right => this.right(y),
|
||||
Placement::Bottom => this.bottom(y),
|
||||
Placement::Left => this.left(y),
|
||||
})
|
||||
},
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
713
crates/ui/src/dropdown.rs
Normal file
713
crates/ui/src/dropdown.rs
Normal file
@@ -0,0 +1,713 @@
|
||||
use gpui::{
|
||||
actions, anchored, canvas, deferred, div, prelude::FluentBuilder, px, rems, AnyElement,
|
||||
AppContext, Bounds, ClickEvent, DismissEvent, ElementId, EventEmitter, FocusHandle,
|
||||
FocusableView, InteractiveElement, IntoElement, KeyBinding, Length, ParentElement, Pixels,
|
||||
Render, SharedString, StatefulInteractiveElement, Styled, Task, View, ViewContext,
|
||||
VisualContext, WeakView, WindowContext,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
h_flex,
|
||||
input::ClearButton,
|
||||
list::{self, List, ListDelegate, ListItem},
|
||||
theme::ActiveTheme,
|
||||
v_flex, Disableable, Icon, IconName, Sizable, Size, StyleSized, StyledExt,
|
||||
};
|
||||
|
||||
actions!(dropdown, [Up, Down, Enter, Escape]);
|
||||
|
||||
const CONTEXT: &str = "Dropdown";
|
||||
pub fn init(cx: &mut AppContext) {
|
||||
cx.bind_keys([
|
||||
KeyBinding::new("up", Up, Some(CONTEXT)),
|
||||
KeyBinding::new("down", Down, Some(CONTEXT)),
|
||||
KeyBinding::new("enter", Enter, Some(CONTEXT)),
|
||||
KeyBinding::new("escape", Escape, Some(CONTEXT)),
|
||||
])
|
||||
}
|
||||
|
||||
/// A trait for items that can be displayed in a dropdown.
|
||||
pub trait DropdownItem {
|
||||
type Value: Clone;
|
||||
fn title(&self) -> SharedString;
|
||||
fn value(&self) -> &Self::Value;
|
||||
}
|
||||
|
||||
impl DropdownItem for String {
|
||||
type Value = Self;
|
||||
|
||||
fn title(&self) -> SharedString {
|
||||
SharedString::from(self.to_string())
|
||||
}
|
||||
|
||||
fn value(&self) -> &Self::Value {
|
||||
&self
|
||||
}
|
||||
}
|
||||
|
||||
impl DropdownItem for SharedString {
|
||||
type Value = Self;
|
||||
|
||||
fn title(&self) -> SharedString {
|
||||
SharedString::from(self.to_string())
|
||||
}
|
||||
|
||||
fn value(&self) -> &Self::Value {
|
||||
&self
|
||||
}
|
||||
}
|
||||
|
||||
pub trait DropdownDelegate: Sized {
|
||||
type Item: DropdownItem;
|
||||
|
||||
fn len(&self) -> usize;
|
||||
|
||||
fn is_empty(&self) -> bool {
|
||||
self.len() == 0
|
||||
}
|
||||
|
||||
fn get(&self, ix: usize) -> Option<&Self::Item>;
|
||||
|
||||
fn position<V>(&self, value: &V) -> Option<usize>
|
||||
where
|
||||
Self::Item: DropdownItem<Value = V>,
|
||||
V: PartialEq,
|
||||
{
|
||||
(0..self.len()).find(|&i| self.get(i).map_or(false, |item| item.value() == value))
|
||||
}
|
||||
|
||||
fn can_search(&self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn perform_search(&mut self, _query: &str, _cx: &mut ViewContext<Dropdown<Self>>) -> Task<()> {
|
||||
Task::Ready(Some(()))
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: DropdownItem> DropdownDelegate for Vec<T> {
|
||||
type Item = T;
|
||||
|
||||
fn len(&self) -> usize {
|
||||
self.len()
|
||||
}
|
||||
|
||||
fn get(&self, ix: usize) -> Option<&Self::Item> {
|
||||
self.as_slice().get(ix)
|
||||
}
|
||||
|
||||
fn position<V>(&self, value: &V) -> Option<usize>
|
||||
where
|
||||
Self::Item: DropdownItem<Value = V>,
|
||||
V: PartialEq,
|
||||
{
|
||||
self.iter().position(|v| v.value() == value)
|
||||
}
|
||||
}
|
||||
|
||||
struct DropdownListDelegate<D: DropdownDelegate + 'static> {
|
||||
delegate: D,
|
||||
dropdown: WeakView<Dropdown<D>>,
|
||||
selected_index: Option<usize>,
|
||||
}
|
||||
|
||||
impl<D> ListDelegate for DropdownListDelegate<D>
|
||||
where
|
||||
D: DropdownDelegate + 'static,
|
||||
{
|
||||
type Item = ListItem;
|
||||
|
||||
fn items_count(&self, _: &AppContext) -> usize {
|
||||
self.delegate.len()
|
||||
}
|
||||
|
||||
fn confirmed_index(&self, _: &AppContext) -> Option<usize> {
|
||||
self.selected_index
|
||||
}
|
||||
|
||||
fn render_item(&self, ix: usize, cx: &mut gpui::ViewContext<List<Self>>) -> Option<Self::Item> {
|
||||
let selected = self
|
||||
.selected_index
|
||||
.map_or(false, |selected_index| selected_index == ix);
|
||||
let size = self
|
||||
.dropdown
|
||||
.upgrade()
|
||||
.map_or(Size::Medium, |dropdown| dropdown.read(cx).size);
|
||||
|
||||
if let Some(item) = self.delegate.get(ix) {
|
||||
let list_item = ListItem::new(("list-item", ix))
|
||||
.check_icon(IconName::Check)
|
||||
.cursor_pointer()
|
||||
.selected(selected)
|
||||
.input_text_size(size)
|
||||
.list_size(size)
|
||||
.child(div().whitespace_nowrap().child(item.title().to_string()));
|
||||
Some(list_item)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn cancel(&mut self, cx: &mut ViewContext<List<Self>>) {
|
||||
let dropdown = self.dropdown.clone();
|
||||
cx.defer(move |_, cx| {
|
||||
_ = dropdown.update(cx, |this, cx| {
|
||||
this.open = false;
|
||||
this.focus(cx);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
fn confirm(&mut self, ix: Option<usize>, cx: &mut ViewContext<List<Self>>) {
|
||||
self.selected_index = ix;
|
||||
|
||||
let selected_value = self
|
||||
.selected_index
|
||||
.and_then(|ix| self.delegate.get(ix))
|
||||
.map(|item| item.value().clone());
|
||||
let dropdown = self.dropdown.clone();
|
||||
|
||||
cx.defer(move |_, cx| {
|
||||
_ = dropdown.update(cx, |this, cx| {
|
||||
cx.emit(DropdownEvent::Confirm(selected_value.clone()));
|
||||
this.selected_value = selected_value;
|
||||
this.open = false;
|
||||
this.focus(cx);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
fn perform_search(&mut self, query: &str, cx: &mut ViewContext<List<Self>>) -> Task<()> {
|
||||
self.dropdown
|
||||
.upgrade()
|
||||
.map_or(Task::Ready(None), |dropdown| {
|
||||
dropdown.update(cx, |_, cx| self.delegate.perform_search(query, cx))
|
||||
})
|
||||
}
|
||||
|
||||
fn set_selected_index(&mut self, ix: Option<usize>, _: &mut ViewContext<List<Self>>) {
|
||||
self.selected_index = ix;
|
||||
}
|
||||
|
||||
fn render_empty(&self, cx: &mut ViewContext<List<Self>>) -> impl IntoElement {
|
||||
if let Some(empty) = self
|
||||
.dropdown
|
||||
.upgrade()
|
||||
.and_then(|dropdown| dropdown.read(cx).empty.as_ref())
|
||||
{
|
||||
empty(cx).into_any_element()
|
||||
} else {
|
||||
h_flex()
|
||||
.justify_center()
|
||||
.py_6()
|
||||
.text_color(cx.theme().muted_foreground.opacity(0.6))
|
||||
.child(Icon::new(IconName::Inbox).size(px(28.)))
|
||||
.into_any_element()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub enum DropdownEvent<D: DropdownDelegate + 'static> {
|
||||
Confirm(Option<<D::Item as DropdownItem>::Value>),
|
||||
}
|
||||
|
||||
/// A Dropdown element.
|
||||
pub struct Dropdown<D: DropdownDelegate + 'static> {
|
||||
id: ElementId,
|
||||
focus_handle: FocusHandle,
|
||||
list: View<List<DropdownListDelegate<D>>>,
|
||||
size: Size,
|
||||
icon: Option<IconName>,
|
||||
open: bool,
|
||||
cleanable: bool,
|
||||
placeholder: Option<SharedString>,
|
||||
title_prefix: Option<SharedString>,
|
||||
selected_value: Option<<D::Item as DropdownItem>::Value>,
|
||||
empty: Option<Box<dyn Fn(&WindowContext) -> AnyElement + 'static>>,
|
||||
width: Length,
|
||||
menu_width: Length,
|
||||
/// Store the bounds of the input
|
||||
bounds: Bounds<Pixels>,
|
||||
disabled: bool,
|
||||
}
|
||||
|
||||
pub struct SearchableVec<T> {
|
||||
items: Vec<T>,
|
||||
matched_items: Vec<T>,
|
||||
}
|
||||
|
||||
impl<T: DropdownItem + Clone> SearchableVec<T> {
|
||||
pub fn new(items: impl Into<Vec<T>>) -> Self {
|
||||
let items = items.into();
|
||||
Self {
|
||||
items: items.clone(),
|
||||
matched_items: items,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: DropdownItem + Clone> DropdownDelegate for SearchableVec<T> {
|
||||
type Item = T;
|
||||
|
||||
fn len(&self) -> usize {
|
||||
self.matched_items.len()
|
||||
}
|
||||
|
||||
fn get(&self, ix: usize) -> Option<&Self::Item> {
|
||||
self.matched_items.get(ix)
|
||||
}
|
||||
|
||||
fn position<V>(&self, value: &V) -> Option<usize>
|
||||
where
|
||||
Self::Item: DropdownItem<Value = V>,
|
||||
V: PartialEq,
|
||||
{
|
||||
for (ix, item) in self.matched_items.iter().enumerate() {
|
||||
if item.value() == value {
|
||||
return Some(ix);
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
fn can_search(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn perform_search(&mut self, query: &str, _cx: &mut ViewContext<Dropdown<Self>>) -> Task<()> {
|
||||
self.matched_items = self
|
||||
.items
|
||||
.iter()
|
||||
.filter(|item| item.title().to_lowercase().contains(&query.to_lowercase()))
|
||||
.cloned()
|
||||
.collect();
|
||||
|
||||
Task::Ready(Some(()))
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Vec<SharedString>> for SearchableVec<SharedString> {
|
||||
fn from(items: Vec<SharedString>) -> Self {
|
||||
Self {
|
||||
items: items.clone(),
|
||||
matched_items: items,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<D> Dropdown<D>
|
||||
where
|
||||
D: DropdownDelegate + 'static,
|
||||
{
|
||||
pub fn new(
|
||||
id: impl Into<ElementId>,
|
||||
delegate: D,
|
||||
selected_index: Option<usize>,
|
||||
cx: &mut ViewContext<Self>,
|
||||
) -> Self {
|
||||
let focus_handle = cx.focus_handle();
|
||||
let delegate = DropdownListDelegate {
|
||||
delegate,
|
||||
dropdown: cx.view().downgrade(),
|
||||
selected_index,
|
||||
};
|
||||
|
||||
let searchable = delegate.delegate.can_search();
|
||||
|
||||
let list = cx.new_view(|cx| {
|
||||
let mut list = List::new(delegate, cx).max_h(rems(20.));
|
||||
if !searchable {
|
||||
list = list.no_query();
|
||||
}
|
||||
list
|
||||
});
|
||||
|
||||
cx.on_blur(&list.focus_handle(cx), Self::on_blur).detach();
|
||||
cx.on_blur(&focus_handle, Self::on_blur).detach();
|
||||
|
||||
let mut this = Self {
|
||||
id: id.into(),
|
||||
focus_handle,
|
||||
placeholder: None,
|
||||
list,
|
||||
size: Size::Medium,
|
||||
icon: None,
|
||||
selected_value: None,
|
||||
open: false,
|
||||
cleanable: false,
|
||||
title_prefix: None,
|
||||
empty: None,
|
||||
width: Length::Auto,
|
||||
menu_width: Length::Auto,
|
||||
bounds: Bounds::default(),
|
||||
disabled: false,
|
||||
};
|
||||
this.set_selected_index(selected_index, cx);
|
||||
this
|
||||
}
|
||||
|
||||
/// Set the width of the dropdown input, default: Length::Auto
|
||||
pub fn width(mut self, width: impl Into<Length>) -> Self {
|
||||
self.width = width.into();
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the width of the dropdown menu, default: Length::Auto
|
||||
pub fn menu_width(mut self, width: impl Into<Length>) -> Self {
|
||||
self.menu_width = width.into();
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the placeholder for display when dropdown value is empty.
|
||||
pub fn placeholder(mut self, placeholder: impl Into<SharedString>) -> Self {
|
||||
self.placeholder = Some(placeholder.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the right icon for the dropdown input, instead of the default arrow icon.
|
||||
pub fn icon(mut self, icon: impl Into<IconName>) -> Self {
|
||||
self.icon = Some(icon.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// Set title prefix for the dropdown.
|
||||
///
|
||||
/// e.g.: Country: United States
|
||||
///
|
||||
/// You should set the label is `Country: `
|
||||
pub fn title_prefix(mut self, prefix: impl Into<SharedString>) -> Self {
|
||||
self.title_prefix = Some(prefix.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// Set true to show the clear button when the input field is not empty.
|
||||
pub fn cleanable(mut self) -> Self {
|
||||
self.cleanable = true;
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the disable state for the dropdown.
|
||||
pub fn disabled(mut self, disabled: bool) -> Self {
|
||||
self.disabled = disabled;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn set_disabled(&mut self, disabled: bool) {
|
||||
self.disabled = disabled;
|
||||
}
|
||||
|
||||
pub fn empty<E, F>(mut self, f: F) -> Self
|
||||
where
|
||||
E: IntoElement,
|
||||
F: Fn(&WindowContext) -> E + 'static,
|
||||
{
|
||||
self.empty = Some(Box::new(move |cx| f(cx).into_any_element()));
|
||||
self
|
||||
}
|
||||
|
||||
pub fn set_selected_index(
|
||||
&mut self,
|
||||
selected_index: Option<usize>,
|
||||
cx: &mut ViewContext<Self>,
|
||||
) {
|
||||
self.list.update(cx, |list, cx| {
|
||||
list.set_selected_index(selected_index, cx);
|
||||
});
|
||||
self.update_selected_value(cx);
|
||||
}
|
||||
|
||||
pub fn set_selected_value(
|
||||
&mut self,
|
||||
selected_value: &<D::Item as DropdownItem>::Value,
|
||||
cx: &mut ViewContext<Self>,
|
||||
) where
|
||||
<<D as DropdownDelegate>::Item as DropdownItem>::Value: PartialEq,
|
||||
{
|
||||
let delegate = self.list.read(cx).delegate();
|
||||
let selected_index = delegate.delegate.position(selected_value);
|
||||
self.set_selected_index(selected_index, cx);
|
||||
}
|
||||
|
||||
pub fn selected_index(&self, cx: &WindowContext) -> Option<usize> {
|
||||
self.list.read(cx).selected_index()
|
||||
}
|
||||
|
||||
fn update_selected_value(&mut self, cx: &WindowContext) {
|
||||
self.selected_value = self
|
||||
.selected_index(cx)
|
||||
.and_then(|ix| self.list.read(cx).delegate().delegate.get(ix))
|
||||
.map(|item| item.value().clone());
|
||||
}
|
||||
|
||||
pub fn selected_value(&self) -> Option<&<D::Item as DropdownItem>::Value> {
|
||||
self.selected_value.as_ref()
|
||||
}
|
||||
|
||||
pub fn focus(&self, cx: &mut WindowContext) {
|
||||
self.focus_handle.focus(cx);
|
||||
}
|
||||
|
||||
fn on_blur(&mut self, cx: &mut ViewContext<Self>) {
|
||||
// When the dropdown and dropdown menu are both not focused, close the dropdown menu.
|
||||
if self.list.focus_handle(cx).is_focused(cx) || self.focus_handle.is_focused(cx) {
|
||||
return;
|
||||
}
|
||||
|
||||
self.open = false;
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn up(&mut self, _: &Up, cx: &mut ViewContext<Self>) {
|
||||
if !self.open {
|
||||
return;
|
||||
}
|
||||
self.list.focus_handle(cx).focus(cx);
|
||||
cx.dispatch_action(Box::new(list::SelectPrev));
|
||||
}
|
||||
|
||||
fn down(&mut self, _: &Down, cx: &mut ViewContext<Self>) {
|
||||
if !self.open {
|
||||
self.open = true;
|
||||
}
|
||||
|
||||
self.list.focus_handle(cx).focus(cx);
|
||||
cx.dispatch_action(Box::new(list::SelectNext));
|
||||
}
|
||||
|
||||
fn enter(&mut self, _: &Enter, cx: &mut ViewContext<Self>) {
|
||||
// Propagate the event to the parent view, for example to the Modal to support ENTER to confirm.
|
||||
cx.propagate();
|
||||
|
||||
if !self.open {
|
||||
self.open = true;
|
||||
cx.notify();
|
||||
} else {
|
||||
self.list.focus_handle(cx).focus(cx);
|
||||
cx.dispatch_action(Box::new(list::Confirm));
|
||||
}
|
||||
}
|
||||
|
||||
fn toggle_menu(&mut self, _: &ClickEvent, cx: &mut ViewContext<Self>) {
|
||||
cx.stop_propagation();
|
||||
|
||||
self.open = !self.open;
|
||||
if self.open {
|
||||
self.list.focus_handle(cx).focus(cx);
|
||||
}
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn escape(&mut self, _: &Escape, cx: &mut ViewContext<Self>) {
|
||||
// Propagate the event to the parent view, for example to the Modal to support ESC to close.
|
||||
cx.propagate();
|
||||
|
||||
self.open = false;
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn clean(&mut self, _: &ClickEvent, cx: &mut ViewContext<Self>) {
|
||||
self.set_selected_index(None, cx);
|
||||
cx.emit(DropdownEvent::Confirm(None));
|
||||
}
|
||||
|
||||
fn display_title(&self, cx: &WindowContext) -> impl IntoElement {
|
||||
let title = if let Some(selected_index) = &self.selected_index(cx) {
|
||||
let title = self
|
||||
.list
|
||||
.read(cx)
|
||||
.delegate()
|
||||
.delegate
|
||||
.get(*selected_index)
|
||||
.map(|item| item.title().to_string())
|
||||
.unwrap_or_default();
|
||||
|
||||
h_flex()
|
||||
.when_some(self.title_prefix.clone(), |this, prefix| this.child(prefix))
|
||||
.child(title.clone())
|
||||
} else {
|
||||
div().text_color(cx.theme().accent_foreground).child(
|
||||
self.placeholder
|
||||
.clone()
|
||||
.unwrap_or_else(|| "Please select".into()),
|
||||
)
|
||||
};
|
||||
|
||||
title.when(self.disabled, |this| {
|
||||
this.cursor_not_allowed()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl<D> Sizable for Dropdown<D>
|
||||
where
|
||||
D: DropdownDelegate + 'static,
|
||||
{
|
||||
fn with_size(mut self, size: impl Into<Size>) -> Self {
|
||||
self.size = size.into();
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl<D> EventEmitter<DropdownEvent<D>> for Dropdown<D> where D: DropdownDelegate + 'static {}
|
||||
impl<D> EventEmitter<DismissEvent> for Dropdown<D> where D: DropdownDelegate + 'static {}
|
||||
impl<D> FocusableView for Dropdown<D>
|
||||
where
|
||||
D: DropdownDelegate + 'static,
|
||||
{
|
||||
fn focus_handle(&self, cx: &AppContext) -> FocusHandle {
|
||||
if self.open {
|
||||
self.list.focus_handle(cx)
|
||||
} else {
|
||||
self.focus_handle.clone()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<D> Render for Dropdown<D>
|
||||
where
|
||||
D: DropdownDelegate + 'static,
|
||||
{
|
||||
fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
|
||||
let is_focused = self.focus_handle.is_focused(cx);
|
||||
let show_clean = self.cleanable && self.selected_index(cx).is_some();
|
||||
let view = cx.view().clone();
|
||||
let bounds = self.bounds;
|
||||
let allow_open = !(self.open || self.disabled);
|
||||
let outline_visible = self.open || is_focused && !self.disabled;
|
||||
|
||||
// If the size has change, set size to self.list, to change the QueryInput size.
|
||||
if self.list.read(cx).size != self.size {
|
||||
self.list
|
||||
.update(cx, |this, cx| this.set_size(self.size, cx))
|
||||
}
|
||||
|
||||
div()
|
||||
.id(self.id.clone())
|
||||
.key_context(CONTEXT)
|
||||
.track_focus(&self.focus_handle)
|
||||
.on_action(cx.listener(Self::up))
|
||||
.on_action(cx.listener(Self::down))
|
||||
.on_action(cx.listener(Self::enter))
|
||||
.on_action(cx.listener(Self::escape))
|
||||
.size_full()
|
||||
.relative()
|
||||
.input_text_size(self.size)
|
||||
.child(
|
||||
div()
|
||||
.id("dropdown-input")
|
||||
.relative()
|
||||
.flex()
|
||||
.items_center()
|
||||
.justify_between()
|
||||
.bg(cx.theme().background)
|
||||
.border_1()
|
||||
.border_color(cx.theme().input)
|
||||
.rounded(px(cx.theme().radius))
|
||||
.when(cx.theme().shadow, |this| this.shadow_sm())
|
||||
.map(|this| {
|
||||
if self.disabled {
|
||||
this.cursor_not_allowed()
|
||||
} else {
|
||||
this.cursor_pointer()
|
||||
}
|
||||
})
|
||||
.overflow_hidden()
|
||||
.input_text_size(self.size)
|
||||
.map(|this| match self.width {
|
||||
Length::Definite(l) => this.flex_none().w(l),
|
||||
Length::Auto => this.w_full(),
|
||||
})
|
||||
.when(outline_visible, |this| this.outline(cx))
|
||||
.input_size(self.size)
|
||||
.when(allow_open, |this| {
|
||||
this.on_click(cx.listener(Self::toggle_menu))
|
||||
})
|
||||
.child(
|
||||
h_flex()
|
||||
.w_full()
|
||||
.items_center()
|
||||
.justify_between()
|
||||
.gap_1()
|
||||
.child(
|
||||
div()
|
||||
.w_full()
|
||||
.overflow_hidden()
|
||||
.child(self.display_title(cx)),
|
||||
)
|
||||
.when(show_clean, |this| {
|
||||
this.child(ClearButton::new(cx).map(|this| {
|
||||
if self.disabled {
|
||||
this.disabled(true)
|
||||
} else {
|
||||
this.on_click(cx.listener(Self::clean))
|
||||
}
|
||||
}))
|
||||
})
|
||||
.when(!show_clean, |this| {
|
||||
let icon = match self.icon.clone() {
|
||||
Some(icon) => icon,
|
||||
None => {
|
||||
if self.open {
|
||||
IconName::ChevronUp
|
||||
} else {
|
||||
IconName::ChevronDown
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
this.child(
|
||||
Icon::new(icon)
|
||||
.xsmall()
|
||||
.text_color(match self.disabled {
|
||||
true => cx.theme().muted_foreground.opacity(0.5),
|
||||
false => cx.theme().muted_foreground,
|
||||
})
|
||||
.when(self.disabled, |this| this.cursor_not_allowed()),
|
||||
)
|
||||
}),
|
||||
)
|
||||
.child(
|
||||
canvas(
|
||||
move |bounds, cx| view.update(cx, |r, _| r.bounds = bounds),
|
||||
|_, _, _| {},
|
||||
)
|
||||
.absolute()
|
||||
.size_full(),
|
||||
),
|
||||
)
|
||||
.when(self.open, |this| {
|
||||
this.child(
|
||||
deferred(
|
||||
anchored().snap_to_window_with_margin(px(8.)).child(
|
||||
div()
|
||||
.occlude()
|
||||
.map(|this| match self.menu_width {
|
||||
Length::Auto => this.w(bounds.size.width),
|
||||
Length::Definite(w) => this.w(w),
|
||||
})
|
||||
.child(
|
||||
v_flex()
|
||||
.occlude()
|
||||
.mt_1p5()
|
||||
.bg(cx.theme().background)
|
||||
.border_1()
|
||||
.border_color(cx.theme().border)
|
||||
.rounded(px(cx.theme().radius))
|
||||
.shadow_md()
|
||||
.on_mouse_down_out(|_, cx| {
|
||||
cx.dispatch_action(Box::new(Escape));
|
||||
})
|
||||
.child(self.list.clone()),
|
||||
)
|
||||
.on_mouse_down_out(cx.listener(|this, _, cx| {
|
||||
this.escape(&Escape, cx);
|
||||
})),
|
||||
),
|
||||
)
|
||||
.with_priority(1),
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
22
crates/ui/src/event.rs
Normal file
22
crates/ui/src/event.rs
Normal file
@@ -0,0 +1,22 @@
|
||||
use gpui::{ClickEvent, Focusable, InteractiveElement, Stateful, WindowContext};
|
||||
|
||||
pub trait InteractiveElementExt: InteractiveElement {
|
||||
/// Set the listener for a double click event.
|
||||
fn on_double_click(
|
||||
mut self,
|
||||
listener: impl Fn(&ClickEvent, &mut WindowContext) + 'static,
|
||||
) -> Self
|
||||
where
|
||||
Self: Sized,
|
||||
{
|
||||
self.interactivity().on_click(move |event, context| {
|
||||
if event.up.click_count == 2 {
|
||||
listener(event, context);
|
||||
}
|
||||
});
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl<E: InteractiveElement> InteractiveElementExt for Focusable<E> {}
|
||||
impl<E: InteractiveElement> InteractiveElementExt for Stateful<E> {}
|
||||
40
crates/ui/src/focusable.rs
Normal file
40
crates/ui/src/focusable.rs
Normal file
@@ -0,0 +1,40 @@
|
||||
use gpui::{FocusHandle, ViewContext};
|
||||
|
||||
/// A trait for views that can cycle focus between its children.
|
||||
///
|
||||
/// This will provide a default implementation for the `cycle_focus` method that will cycle focus.
|
||||
///
|
||||
/// You should implement the `cycle_focus_handles` method to return a list of focus handles that
|
||||
/// should be cycled, and the cycle will follow the order of the list.
|
||||
pub trait FocusableCycle {
|
||||
/// Returns a list of focus handles that should be cycled.
|
||||
fn cycle_focus_handles(&self, cx: &mut ViewContext<Self>) -> Vec<FocusHandle>
|
||||
where
|
||||
Self: Sized;
|
||||
|
||||
/// Cycles focus between the focus handles returned by `cycle_focus_handles`.
|
||||
/// If `is_next` is `true`, it will cycle to the next focus handle, otherwise it will cycle to prev.
|
||||
fn cycle_focus(&self, is_next: bool, cx: &mut ViewContext<Self>)
|
||||
where
|
||||
Self: Sized,
|
||||
{
|
||||
let focused_handle = cx.focused();
|
||||
let handles = self.cycle_focus_handles(cx);
|
||||
let handles = if is_next {
|
||||
handles
|
||||
} else {
|
||||
handles.into_iter().rev().collect()
|
||||
};
|
||||
|
||||
let fallback_handle = handles[0].clone();
|
||||
let target_focus_handle = handles
|
||||
.into_iter()
|
||||
.skip_while(|handle| Some(handle) != focused_handle.as_ref())
|
||||
.skip(1)
|
||||
.next()
|
||||
.unwrap_or(fallback_handle);
|
||||
|
||||
target_focus_handle.focus(cx);
|
||||
cx.stop_propagation();
|
||||
}
|
||||
}
|
||||
197
crates/ui/src/history.rs
Normal file
197
crates/ui/src/history.rs
Normal file
@@ -0,0 +1,197 @@
|
||||
use std::{
|
||||
fmt::Debug,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
pub trait HistoryItem: Clone {
|
||||
fn version(&self) -> usize;
|
||||
fn set_version(&mut self, version: usize);
|
||||
}
|
||||
|
||||
/// The History is used to keep track of changes to a model and to allow undo and redo operations.
|
||||
///
|
||||
/// This is now used in Input for undo/redo operations. You can also use this in
|
||||
/// your own models to keep track of changes, for example to track the tab
|
||||
/// history for prev/next features.
|
||||
#[derive(Debug)]
|
||||
pub struct History<I: HistoryItem> {
|
||||
undos: Vec<I>,
|
||||
redos: Vec<I>,
|
||||
last_changed_at: Instant,
|
||||
version: usize,
|
||||
pub(crate) ignore: bool,
|
||||
max_undo: usize,
|
||||
group_interval: Option<Duration>,
|
||||
}
|
||||
|
||||
impl<I> History<I>
|
||||
where
|
||||
I: HistoryItem,
|
||||
{
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
undos: Default::default(),
|
||||
redos: Default::default(),
|
||||
ignore: false,
|
||||
last_changed_at: Instant::now(),
|
||||
version: 0,
|
||||
max_undo: 1000,
|
||||
group_interval: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the maximum number of undo steps to keep, defaults to 1000.
|
||||
pub fn max_undo(mut self, max_undo: usize) -> Self {
|
||||
self.max_undo = max_undo;
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the interval in milliseconds to group changes, defaults to None.
|
||||
pub fn group_interval(mut self, group_interval: Duration) -> Self {
|
||||
self.group_interval = Some(group_interval);
|
||||
self
|
||||
}
|
||||
|
||||
/// Increment the version number if the last change was made more than `GROUP_INTERVAL` milliseconds ago.
|
||||
fn inc_version(&mut self) -> usize {
|
||||
let t = Instant::now();
|
||||
if Some(self.last_changed_at.elapsed()) > self.group_interval {
|
||||
self.version += 1;
|
||||
}
|
||||
|
||||
self.last_changed_at = t;
|
||||
self.version
|
||||
}
|
||||
|
||||
/// Get the current version number.
|
||||
pub fn version(&self) -> usize {
|
||||
self.version
|
||||
}
|
||||
|
||||
pub fn push(&mut self, item: I) {
|
||||
let version = self.inc_version();
|
||||
|
||||
if self.undos.len() >= self.max_undo {
|
||||
self.undos.remove(0);
|
||||
}
|
||||
|
||||
let mut item = item;
|
||||
item.set_version(version);
|
||||
self.undos.push(item);
|
||||
}
|
||||
|
||||
pub fn undo(&mut self) -> Option<Vec<I>> {
|
||||
if let Some(first_change) = self.undos.pop() {
|
||||
let mut changes = vec![first_change.clone()];
|
||||
// pick the next all changes with the same version
|
||||
while self
|
||||
.undos
|
||||
.iter()
|
||||
.filter(|c| c.version() == first_change.version())
|
||||
.count()
|
||||
> 0
|
||||
{
|
||||
let change = self.undos.pop().unwrap();
|
||||
changes.push(change);
|
||||
}
|
||||
|
||||
self.redos.extend(changes.iter().rev().cloned());
|
||||
Some(changes)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
pub fn redo(&mut self) -> Option<Vec<I>> {
|
||||
if let Some(first_change) = self.redos.pop() {
|
||||
let mut changes = vec![first_change.clone()];
|
||||
// pick the next all changes with the same version
|
||||
while self
|
||||
.redos
|
||||
.iter()
|
||||
.filter(|c| c.version() == first_change.version())
|
||||
.count()
|
||||
> 0
|
||||
{
|
||||
let change = self.redos.pop().unwrap();
|
||||
changes.push(change);
|
||||
}
|
||||
self.undos.extend(changes.iter().rev().cloned());
|
||||
Some(changes)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[derive(Clone)]
|
||||
struct TabIndex {
|
||||
tab_index: usize,
|
||||
version: usize,
|
||||
}
|
||||
|
||||
impl From<usize> for TabIndex {
|
||||
fn from(value: usize) -> Self {
|
||||
TabIndex {
|
||||
tab_index: value,
|
||||
version: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl HistoryItem for TabIndex {
|
||||
fn version(&self) -> usize {
|
||||
self.version
|
||||
}
|
||||
fn set_version(&mut self, version: usize) {
|
||||
self.version = version;
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_history() {
|
||||
let mut history: History<TabIndex> = History::new().max_undo(100);
|
||||
history.push(0.into());
|
||||
history.push(3.into());
|
||||
history.push(2.into());
|
||||
history.push(1.into());
|
||||
|
||||
assert_eq!(history.version(), 4);
|
||||
let changes = history.undo().unwrap();
|
||||
assert_eq!(changes.len(), 1);
|
||||
assert_eq!(changes[0].tab_index, 1);
|
||||
|
||||
let changes = history.undo().unwrap();
|
||||
assert_eq!(changes.len(), 1);
|
||||
assert_eq!(changes[0].tab_index, 2);
|
||||
|
||||
history.push(5.into());
|
||||
|
||||
let changes = history.redo().unwrap();
|
||||
assert_eq!(changes[0].tab_index, 2);
|
||||
|
||||
let changes = history.redo().unwrap();
|
||||
assert_eq!(changes[0].tab_index, 1);
|
||||
|
||||
let changes = history.undo().unwrap();
|
||||
assert_eq!(changes[0].tab_index, 1);
|
||||
|
||||
let changes = history.undo().unwrap();
|
||||
assert_eq!(changes[0].tab_index, 2);
|
||||
|
||||
let changes = history.undo().unwrap();
|
||||
assert_eq!(changes[0].tab_index, 5);
|
||||
|
||||
let changes = history.undo().unwrap();
|
||||
assert_eq!(changes[0].tab_index, 3);
|
||||
|
||||
let changes = history.undo().unwrap();
|
||||
assert_eq!(changes[0].tab_index, 0);
|
||||
|
||||
assert_eq!(history.undo().is_none(), true);
|
||||
}
|
||||
}
|
||||
319
crates/ui/src/icon.rs
Normal file
319
crates/ui/src/icon.rs
Normal file
@@ -0,0 +1,319 @@
|
||||
use crate::{theme::ActiveTheme, Sizable, Size};
|
||||
use gpui::{
|
||||
prelude::FluentBuilder as _, svg, AnyElement, Hsla, IntoElement, Radians, Render, RenderOnce,
|
||||
SharedString, StyleRefinement, Styled, Svg, Transformation, View, VisualContext, WindowContext,
|
||||
};
|
||||
|
||||
#[derive(IntoElement, Clone)]
|
||||
pub enum IconName {
|
||||
ALargeSmall,
|
||||
ArrowDown,
|
||||
ArrowLeft,
|
||||
ArrowRight,
|
||||
ArrowUp,
|
||||
Asterisk,
|
||||
Bell,
|
||||
BookOpen,
|
||||
Bot,
|
||||
Calendar,
|
||||
ChartPie,
|
||||
Check,
|
||||
ChevronDown,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
ChevronUp,
|
||||
ChevronsUpDown,
|
||||
CircleCheck,
|
||||
CircleUser,
|
||||
CircleX,
|
||||
Close,
|
||||
Copy,
|
||||
Dash,
|
||||
Delete,
|
||||
Ellipsis,
|
||||
EllipsisVertical,
|
||||
Eye,
|
||||
EyeOff,
|
||||
Frame,
|
||||
GalleryVerticalEnd,
|
||||
GitHub,
|
||||
Globe,
|
||||
Heart,
|
||||
HeartOff,
|
||||
Inbox,
|
||||
Info,
|
||||
LayoutDashboard,
|
||||
Loader,
|
||||
LoaderCircle,
|
||||
Map,
|
||||
Maximize,
|
||||
Menu,
|
||||
Minimize,
|
||||
Minus,
|
||||
Moon,
|
||||
Palette,
|
||||
PanelBottom,
|
||||
PanelBottomOpen,
|
||||
PanelLeft,
|
||||
PanelLeftClose,
|
||||
PanelLeftOpen,
|
||||
PanelRight,
|
||||
PanelRightClose,
|
||||
PanelRightOpen,
|
||||
Plus,
|
||||
Search,
|
||||
Settings,
|
||||
Settings2,
|
||||
SortAscending,
|
||||
SortDescending,
|
||||
SquareTerminal,
|
||||
Star,
|
||||
StarOff,
|
||||
Sun,
|
||||
ThumbsDown,
|
||||
ThumbsUp,
|
||||
TriangleAlert,
|
||||
WindowClose,
|
||||
WindowMaximize,
|
||||
WindowMinimize,
|
||||
WindowRestore,
|
||||
}
|
||||
|
||||
impl IconName {
|
||||
pub fn path(self) -> SharedString {
|
||||
match self {
|
||||
IconName::ALargeSmall => "icons/a-large-small.svg",
|
||||
IconName::ArrowDown => "icons/arrow-down.svg",
|
||||
IconName::ArrowLeft => "icons/arrow-left.svg",
|
||||
IconName::ArrowRight => "icons/arrow-right.svg",
|
||||
IconName::ArrowUp => "icons/arrow-up.svg",
|
||||
IconName::Asterisk => "icons/asterisk.svg",
|
||||
IconName::Bell => "icons/bell.svg",
|
||||
IconName::BookOpen => "icons/book-open.svg",
|
||||
IconName::Bot => "icons/bot.svg",
|
||||
IconName::Calendar => "icons/calendar.svg",
|
||||
IconName::ChartPie => "icons/chart-pie.svg",
|
||||
IconName::Check => "icons/check.svg",
|
||||
IconName::ChevronDown => "icons/chevron-down.svg",
|
||||
IconName::ChevronLeft => "icons/chevron-left.svg",
|
||||
IconName::ChevronRight => "icons/chevron-right.svg",
|
||||
IconName::ChevronUp => "icons/chevron-up.svg",
|
||||
IconName::ChevronsUpDown => "icons/chevrons-up-down.svg",
|
||||
IconName::CircleCheck => "icons/circle-check.svg",
|
||||
IconName::CircleUser => "icons/circle-user.svg",
|
||||
IconName::CircleX => "icons/circle-x.svg",
|
||||
IconName::Close => "icons/close.svg",
|
||||
IconName::Copy => "icons/copy.svg",
|
||||
IconName::Dash => "icons/dash.svg",
|
||||
IconName::Delete => "icons/delete.svg",
|
||||
IconName::Ellipsis => "icons/ellipsis.svg",
|
||||
IconName::EllipsisVertical => "icons/ellipsis-vertical.svg",
|
||||
IconName::Eye => "icons/eye.svg",
|
||||
IconName::EyeOff => "icons/eye-off.svg",
|
||||
IconName::Frame => "icons/frame.svg",
|
||||
IconName::GalleryVerticalEnd => "icons/gallery-vertical-end.svg",
|
||||
IconName::GitHub => "icons/github.svg",
|
||||
IconName::Globe => "icons/globe.svg",
|
||||
IconName::Heart => "icons/heart.svg",
|
||||
IconName::HeartOff => "icons/heart-off.svg",
|
||||
IconName::Inbox => "icons/inbox.svg",
|
||||
IconName::Info => "icons/info.svg",
|
||||
IconName::LayoutDashboard => "icons/layout-dashboard.svg",
|
||||
IconName::Loader => "icons/loader.svg",
|
||||
IconName::LoaderCircle => "icons/loader-circle.svg",
|
||||
IconName::Map => "icons/map.svg",
|
||||
IconName::Maximize => "icons/maximize.svg",
|
||||
IconName::Menu => "icons/menu.svg",
|
||||
IconName::Minimize => "icons/minimize.svg",
|
||||
IconName::Minus => "icons/minus.svg",
|
||||
IconName::Moon => "icons/moon.svg",
|
||||
IconName::Palette => "icons/palette.svg",
|
||||
IconName::PanelBottom => "icons/panel-bottom.svg",
|
||||
IconName::PanelBottomOpen => "icons/panel-bottom-open.svg",
|
||||
IconName::PanelLeft => "icons/panel-left.svg",
|
||||
IconName::PanelLeftClose => "icons/panel-left-close.svg",
|
||||
IconName::PanelLeftOpen => "icons/panel-left-open.svg",
|
||||
IconName::PanelRight => "icons/panel-right.svg",
|
||||
IconName::PanelRightClose => "icons/panel-right-close.svg",
|
||||
IconName::PanelRightOpen => "icons/panel-right-open.svg",
|
||||
IconName::Plus => "icons/plus.svg",
|
||||
IconName::Search => "icons/search.svg",
|
||||
IconName::Settings => "icons/settings.svg",
|
||||
IconName::Settings2 => "icons/settings-2.svg",
|
||||
IconName::SortAscending => "icons/sort-ascending.svg",
|
||||
IconName::SortDescending => "icons/sort-descending.svg",
|
||||
IconName::SquareTerminal => "icons/square-terminal.svg",
|
||||
IconName::Star => "icons/star.svg",
|
||||
IconName::StarOff => "icons/star-off.svg",
|
||||
IconName::Sun => "icons/sun.svg",
|
||||
IconName::ThumbsDown => "icons/thumbs-down.svg",
|
||||
IconName::ThumbsUp => "icons/thumbs-up.svg",
|
||||
IconName::TriangleAlert => "icons/triangle-alert.svg",
|
||||
IconName::WindowClose => "icons/window-close.svg",
|
||||
IconName::WindowMaximize => "icons/window-maximize.svg",
|
||||
IconName::WindowMinimize => "icons/window-minimize.svg",
|
||||
IconName::WindowRestore => "icons/window-restore.svg",
|
||||
}
|
||||
.into()
|
||||
}
|
||||
|
||||
/// Return the icon as a View<Icon>
|
||||
pub fn view(self, cx: &mut WindowContext) -> View<Icon> {
|
||||
Icon::build(self).view(cx)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<IconName> for Icon {
|
||||
fn from(val: IconName) -> Self {
|
||||
Icon::build(val)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<IconName> for AnyElement {
|
||||
fn from(val: IconName) -> Self {
|
||||
Icon::build(val).into_any_element()
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for IconName {
|
||||
fn render(self, _cx: &mut WindowContext) -> impl IntoElement {
|
||||
Icon::build(self)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(IntoElement)]
|
||||
pub struct Icon {
|
||||
base: Svg,
|
||||
path: SharedString,
|
||||
text_color: Option<Hsla>,
|
||||
size: Option<Size>,
|
||||
rotation: Option<Radians>,
|
||||
}
|
||||
|
||||
impl Default for Icon {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
base: svg().flex_none().size_4(),
|
||||
path: "".into(),
|
||||
text_color: None,
|
||||
size: None,
|
||||
rotation: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Clone for Icon {
|
||||
fn clone(&self) -> Self {
|
||||
let mut this = Self::default().path(self.path.clone());
|
||||
if let Some(size) = self.size {
|
||||
this = this.with_size(size);
|
||||
}
|
||||
this
|
||||
}
|
||||
}
|
||||
|
||||
pub trait IconNamed {
|
||||
fn path(&self) -> SharedString;
|
||||
}
|
||||
|
||||
impl Icon {
|
||||
pub fn new(icon: impl Into<Icon>) -> Self {
|
||||
icon.into()
|
||||
}
|
||||
|
||||
fn build(name: IconName) -> Self {
|
||||
Self::default().path(name.path())
|
||||
}
|
||||
|
||||
/// Set the icon path of the Assets bundle
|
||||
///
|
||||
/// For example: `icons/foo.svg`
|
||||
pub fn path(mut self, path: impl Into<SharedString>) -> Self {
|
||||
self.path = path.into();
|
||||
self
|
||||
}
|
||||
|
||||
/// Create a new view for the icon
|
||||
pub fn view(self, cx: &mut WindowContext) -> View<Icon> {
|
||||
cx.new_view(|_| self)
|
||||
}
|
||||
|
||||
pub fn transform(mut self, transformation: gpui::Transformation) -> Self {
|
||||
self.base = self.base.with_transformation(transformation);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn empty() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Rotate the icon by the given angle
|
||||
pub fn rotate(mut self, radians: impl Into<Radians>) -> Self {
|
||||
self.base = self
|
||||
.base
|
||||
.with_transformation(Transformation::rotate(radians));
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl Styled for Icon {
|
||||
fn style(&mut self) -> &mut StyleRefinement {
|
||||
self.base.style()
|
||||
}
|
||||
|
||||
fn text_color(mut self, color: impl Into<Hsla>) -> Self {
|
||||
self.text_color = Some(color.into());
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl Sizable for Icon {
|
||||
fn with_size(mut self, size: impl Into<Size>) -> Self {
|
||||
self.size = Some(size.into());
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for Icon {
|
||||
fn render(self, cx: &mut WindowContext) -> impl IntoElement {
|
||||
let text_color = self.text_color.unwrap_or_else(|| cx.text_style().color);
|
||||
|
||||
self.base
|
||||
.text_color(text_color)
|
||||
.when_some(self.size, |this, size| match size {
|
||||
Size::Size(px) => this.size(px),
|
||||
Size::XSmall => this.size_3(),
|
||||
Size::Small => this.size_3p5(),
|
||||
Size::Medium => this.size_4(),
|
||||
Size::Large => this.size_6(),
|
||||
})
|
||||
.path(self.path)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Icon> for AnyElement {
|
||||
fn from(val: Icon) -> Self {
|
||||
val.into_any_element()
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for Icon {
|
||||
fn render(&mut self, cx: &mut gpui::ViewContext<Self>) -> impl IntoElement {
|
||||
let text_color = self.text_color.unwrap_or_else(|| cx.theme().foreground);
|
||||
|
||||
svg()
|
||||
.flex_none()
|
||||
.text_color(text_color)
|
||||
.when_some(self.size, |this, size| match size {
|
||||
Size::Size(px) => this.size(px),
|
||||
Size::XSmall => this.size_3(),
|
||||
Size::Small => this.size_3p5(),
|
||||
Size::Medium => this.size_4(),
|
||||
Size::Large => this.size_6(),
|
||||
})
|
||||
.path(self.path.clone())
|
||||
.when_some(self.rotation, |this, rotation| {
|
||||
this.with_transformation(Transformation::rotate(rotation))
|
||||
})
|
||||
}
|
||||
}
|
||||
60
crates/ui/src/indicator.rs
Normal file
60
crates/ui/src/indicator.rs
Normal file
@@ -0,0 +1,60 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::{Icon, IconName, Sizable, Size};
|
||||
use gpui::{
|
||||
div, ease_in_out, percentage, prelude::FluentBuilder as _, Animation, AnimationExt as _, Hsla,
|
||||
IntoElement, ParentElement, RenderOnce, Styled as _, Transformation, WindowContext,
|
||||
};
|
||||
|
||||
#[derive(IntoElement)]
|
||||
pub struct Indicator {
|
||||
size: Size,
|
||||
icon: Icon,
|
||||
speed: Duration,
|
||||
color: Option<Hsla>,
|
||||
}
|
||||
|
||||
impl Indicator {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
size: Size::Medium,
|
||||
speed: Duration::from_secs_f64(0.8),
|
||||
icon: Icon::new(IconName::Loader),
|
||||
color: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn icon(mut self, icon: impl Into<Icon>) -> Self {
|
||||
self.icon = icon.into();
|
||||
self
|
||||
}
|
||||
|
||||
pub fn color(mut self, color: Hsla) -> Self {
|
||||
self.color = Some(color);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl Sizable for Indicator {
|
||||
fn with_size(mut self, size: impl Into<Size>) -> Self {
|
||||
self.size = size.into();
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for Indicator {
|
||||
fn render(self, _: &mut WindowContext) -> impl IntoElement {
|
||||
div()
|
||||
.child(
|
||||
self.icon
|
||||
.with_size(self.size)
|
||||
.when_some(self.color, |this, color| this.text_color(color))
|
||||
.with_animation(
|
||||
"circle",
|
||||
Animation::new(self.speed).repeat().with_easing(ease_in_out),
|
||||
|this, delta| this.transform(Transformation::rotate(percentage(delta))),
|
||||
),
|
||||
)
|
||||
.into_element()
|
||||
}
|
||||
}
|
||||
88
crates/ui/src/input/blink_cursor.rs
Normal file
88
crates/ui/src/input/blink_cursor.rs
Normal file
@@ -0,0 +1,88 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use gpui::{ModelContext, Timer};
|
||||
|
||||
static INTERVAL: Duration = Duration::from_millis(500);
|
||||
static PAUSE_DELAY: Duration = Duration::from_millis(300);
|
||||
|
||||
/// To manage the Input cursor blinking.
|
||||
///
|
||||
/// It will start blinking with a interval of 500ms.
|
||||
/// Every loop will notify the view to update the `visible`, and Input will observe this update to touch repaint.
|
||||
///
|
||||
/// The input painter will check if this in visible state, then it will draw the cursor.
|
||||
pub(crate) struct BlinkCursor {
|
||||
visible: bool,
|
||||
paused: bool,
|
||||
epoch: usize,
|
||||
}
|
||||
|
||||
impl BlinkCursor {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
visible: false,
|
||||
paused: false,
|
||||
epoch: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Start the blinking
|
||||
pub fn start(&mut self, cx: &mut ModelContext<Self>) {
|
||||
self.blink(self.epoch, cx);
|
||||
}
|
||||
|
||||
pub fn stop(&mut self, cx: &mut ModelContext<Self>) {
|
||||
self.epoch = 0;
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn next_epoch(&mut self) -> usize {
|
||||
self.epoch += 1;
|
||||
self.epoch
|
||||
}
|
||||
|
||||
fn blink(&mut self, epoch: usize, cx: &mut ModelContext<Self>) {
|
||||
if self.paused || epoch != self.epoch {
|
||||
return;
|
||||
}
|
||||
|
||||
self.visible = !self.visible;
|
||||
cx.notify();
|
||||
|
||||
// Schedule the next blink
|
||||
let epoch = self.next_epoch();
|
||||
cx.spawn(|this, mut cx| async move {
|
||||
Timer::after(INTERVAL).await;
|
||||
if let Some(this) = this.upgrade() {
|
||||
this.update(&mut cx, |this, cx| this.blink(epoch, cx)).ok();
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
pub fn visible(&self) -> bool {
|
||||
// Keep showing the cursor if paused
|
||||
self.paused || self.visible
|
||||
}
|
||||
|
||||
/// Pause the blinking, and delay 500ms to resume the blinking.
|
||||
pub fn pause(&mut self, cx: &mut ModelContext<Self>) {
|
||||
self.paused = true;
|
||||
cx.notify();
|
||||
|
||||
// delay 500ms to start the blinking
|
||||
let epoch = self.next_epoch();
|
||||
cx.spawn(|this, mut cx| async move {
|
||||
Timer::after(PAUSE_DELAY).await;
|
||||
|
||||
if let Some(this) = this.upgrade() {
|
||||
this.update(&mut cx, |this, cx| {
|
||||
this.paused = false;
|
||||
this.blink(epoch, cx);
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
}
|
||||
39
crates/ui/src/input/change.rs
Normal file
39
crates/ui/src/input/change.rs
Normal file
@@ -0,0 +1,39 @@
|
||||
use std::{fmt::Debug, ops::Range};
|
||||
|
||||
use crate::history::HistoryItem;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Change {
|
||||
pub(crate) old_range: Range<usize>,
|
||||
pub(crate) old_text: String,
|
||||
pub(crate) new_range: Range<usize>,
|
||||
pub(crate) new_text: String,
|
||||
version: usize,
|
||||
}
|
||||
|
||||
impl Change {
|
||||
pub fn new(
|
||||
old_range: Range<usize>,
|
||||
old_text: &str,
|
||||
new_range: Range<usize>,
|
||||
new_text: &str,
|
||||
) -> Self {
|
||||
Self {
|
||||
old_range,
|
||||
old_text: old_text.to_string(),
|
||||
new_range,
|
||||
new_text: new_text.to_string(),
|
||||
version: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl HistoryItem for Change {
|
||||
fn version(&self) -> usize {
|
||||
self.version
|
||||
}
|
||||
|
||||
fn set_version(&mut self, version: usize) {
|
||||
self.version = version;
|
||||
}
|
||||
}
|
||||
18
crates/ui/src/input/clear_button.rs
Normal file
18
crates/ui/src/input/clear_button.rs
Normal file
@@ -0,0 +1,18 @@
|
||||
use gpui::{Styled, WindowContext};
|
||||
|
||||
use crate::{
|
||||
button::{Button, ButtonVariants as _},
|
||||
theme::ActiveTheme as _,
|
||||
Icon, IconName, Sizable as _,
|
||||
};
|
||||
|
||||
pub(crate) struct ClearButton {}
|
||||
|
||||
impl ClearButton {
|
||||
pub fn new(cx: &mut WindowContext) -> Button {
|
||||
Button::new("clean")
|
||||
.icon(Icon::new(IconName::CircleX).text_color(cx.theme().muted_foreground))
|
||||
.ghost()
|
||||
.xsmall()
|
||||
}
|
||||
}
|
||||
537
crates/ui/src/input/element.rs
Normal file
537
crates/ui/src/input/element.rs
Normal file
@@ -0,0 +1,537 @@
|
||||
use gpui::{
|
||||
fill, point, px, relative, size, Bounds, Corners, Element, ElementId, ElementInputHandler,
|
||||
GlobalElementId, IntoElement, LayoutId, MouseButton, MouseMoveEvent, PaintQuad, Path, Pixels,
|
||||
Point, Style, TextRun, UnderlineStyle, View, WindowContext, WrappedLine,
|
||||
};
|
||||
use smallvec::SmallVec;
|
||||
|
||||
use crate::theme::ActiveTheme as _;
|
||||
|
||||
use super::TextInput;
|
||||
|
||||
const RIGHT_MARGIN: Pixels = px(5.);
|
||||
const CURSOR_INSET: Pixels = px(0.5);
|
||||
|
||||
pub(super) struct TextElement {
|
||||
input: View<TextInput>,
|
||||
}
|
||||
|
||||
impl TextElement {
|
||||
pub(super) fn new(input: View<TextInput>) -> Self {
|
||||
Self { input }
|
||||
}
|
||||
|
||||
fn paint_mouse_listeners(&mut self, cx: &mut WindowContext) {
|
||||
cx.on_mouse_event({
|
||||
let input = self.input.clone();
|
||||
|
||||
move |event: &MouseMoveEvent, _, cx| {
|
||||
if event.pressed_button == Some(MouseButton::Left) {
|
||||
input.update(cx, |input, cx| {
|
||||
input.on_drag_move(event, cx);
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn layout_cursor(
|
||||
&self,
|
||||
lines: &[WrappedLine],
|
||||
line_height: Pixels,
|
||||
bounds: &mut Bounds<Pixels>,
|
||||
cx: &mut WindowContext,
|
||||
) -> (Option<PaintQuad>, Point<Pixels>) {
|
||||
let input = self.input.read(cx);
|
||||
let selected_range = &input.selected_range;
|
||||
let cursor_offset = input.cursor_offset();
|
||||
let mut scroll_offset = input.scroll_handle.offset();
|
||||
let mut cursor = None;
|
||||
|
||||
// The cursor corresponds to the current cursor position in the text no only the line.
|
||||
let mut cursor_pos = None;
|
||||
let mut cursor_start = None;
|
||||
let mut cursor_end = None;
|
||||
|
||||
let mut prev_lines_offset = 0;
|
||||
let mut offset_y = px(0.);
|
||||
for line in lines.iter() {
|
||||
// break loop if all cursor positions are found
|
||||
if cursor_pos.is_some() && cursor_start.is_some() && cursor_end.is_some() {
|
||||
break;
|
||||
}
|
||||
|
||||
let line_origin = point(px(0.), offset_y);
|
||||
if cursor_pos.is_none() {
|
||||
let offset = cursor_offset.saturating_sub(prev_lines_offset);
|
||||
if let Some(pos) = line.position_for_index(offset, line_height) {
|
||||
cursor_pos = Some(line_origin + pos);
|
||||
}
|
||||
}
|
||||
if cursor_start.is_none() {
|
||||
let offset = selected_range.start.saturating_sub(prev_lines_offset);
|
||||
if let Some(pos) = line.position_for_index(offset, line_height) {
|
||||
cursor_start = Some(line_origin + pos);
|
||||
}
|
||||
}
|
||||
if cursor_end.is_none() {
|
||||
let offset = selected_range.end.saturating_sub(prev_lines_offset);
|
||||
if let Some(pos) = line.position_for_index(offset, line_height) {
|
||||
cursor_end = Some(line_origin + pos);
|
||||
}
|
||||
}
|
||||
|
||||
offset_y += line.size(line_height).height;
|
||||
// +1 for skip the last `\n`
|
||||
prev_lines_offset += line.len() + 1;
|
||||
}
|
||||
|
||||
if let (Some(cursor_pos), Some(cursor_start), Some(cursor_end)) =
|
||||
(cursor_pos, cursor_start, cursor_end)
|
||||
{
|
||||
let cursor_moved = input.last_cursor_offset != Some(cursor_offset);
|
||||
let selection_changed = input.last_selected_range != Some(selected_range.clone());
|
||||
|
||||
if cursor_moved || selection_changed {
|
||||
scroll_offset.x =
|
||||
if scroll_offset.x + cursor_pos.x > (bounds.size.width - RIGHT_MARGIN) {
|
||||
// cursor is out of right
|
||||
bounds.size.width - RIGHT_MARGIN - cursor_pos.x
|
||||
} else if scroll_offset.x + cursor_pos.x < px(0.) {
|
||||
// cursor is out of left
|
||||
scroll_offset.x - cursor_pos.x
|
||||
} else {
|
||||
scroll_offset.x
|
||||
};
|
||||
scroll_offset.y = if scroll_offset.y + cursor_pos.y > (bounds.size.height) {
|
||||
// cursor is out of bottom
|
||||
bounds.size.height - cursor_pos.y
|
||||
} else if scroll_offset.y + cursor_pos.y < px(0.) {
|
||||
// cursor is out of top
|
||||
scroll_offset.y - cursor_pos.y
|
||||
} else {
|
||||
scroll_offset.y
|
||||
};
|
||||
|
||||
if input.selection_reversed {
|
||||
if scroll_offset.x + cursor_start.x < px(0.) {
|
||||
// selection start is out of left
|
||||
scroll_offset.x = -cursor_start.x;
|
||||
}
|
||||
if scroll_offset.y + cursor_start.y < px(0.) {
|
||||
// selection start is out of top
|
||||
scroll_offset.y = -cursor_start.y;
|
||||
}
|
||||
} else {
|
||||
if scroll_offset.x + cursor_end.x <= px(0.) {
|
||||
// selection end is out of left
|
||||
scroll_offset.x = -cursor_end.x;
|
||||
}
|
||||
if scroll_offset.y + cursor_end.y <= px(0.) {
|
||||
// selection end is out of top
|
||||
scroll_offset.y = -cursor_end.y;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bounds.origin = bounds.origin + scroll_offset;
|
||||
|
||||
if input.show_cursor(cx) {
|
||||
// cursor blink
|
||||
cursor = Some(fill(
|
||||
Bounds::new(
|
||||
point(
|
||||
bounds.left() + cursor_pos.x,
|
||||
bounds.top() + cursor_pos.y + CURSOR_INSET,
|
||||
),
|
||||
size(px(1.5), line_height),
|
||||
),
|
||||
crate::blue_500(),
|
||||
))
|
||||
};
|
||||
}
|
||||
|
||||
(cursor, scroll_offset)
|
||||
}
|
||||
|
||||
fn layout_selections(
|
||||
&self,
|
||||
lines: &[WrappedLine],
|
||||
line_height: Pixels,
|
||||
bounds: &mut Bounds<Pixels>,
|
||||
cx: &mut WindowContext,
|
||||
) -> Option<Path<Pixels>> {
|
||||
let input = self.input.read(cx);
|
||||
let selected_range = &input.selected_range;
|
||||
if selected_range.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let (start_ix, end_ix) = if selected_range.start < selected_range.end {
|
||||
(selected_range.start, selected_range.end)
|
||||
} else {
|
||||
(selected_range.end, selected_range.start)
|
||||
};
|
||||
|
||||
let mut prev_lines_offset = 0;
|
||||
let mut line_corners = vec![];
|
||||
|
||||
let mut offset_y = px(0.);
|
||||
for line in lines.iter() {
|
||||
let line_size = line.size(line_height);
|
||||
let line_wrap_width = line_size.width;
|
||||
|
||||
let line_origin = point(px(0.), offset_y);
|
||||
|
||||
let line_cursor_start =
|
||||
line.position_for_index(start_ix.saturating_sub(prev_lines_offset), line_height);
|
||||
let line_cursor_end =
|
||||
line.position_for_index(end_ix.saturating_sub(prev_lines_offset), line_height);
|
||||
|
||||
if line_cursor_start.is_some() || line_cursor_end.is_some() {
|
||||
let start = line_cursor_start
|
||||
.unwrap_or_else(|| line.position_for_index(0, line_height).unwrap());
|
||||
|
||||
let end = line_cursor_end
|
||||
.unwrap_or_else(|| line.position_for_index(line.len(), line_height).unwrap());
|
||||
|
||||
// Split the selection into multiple items
|
||||
let wrapped_lines =
|
||||
(end.y / line_height).ceil() as usize - (start.y / line_height).ceil() as usize;
|
||||
|
||||
let mut end_x = end.x;
|
||||
if wrapped_lines > 0 {
|
||||
end_x = line_wrap_width;
|
||||
}
|
||||
|
||||
line_corners.push(Corners {
|
||||
top_left: line_origin + point(start.x, start.y),
|
||||
top_right: line_origin + point(end_x, start.y),
|
||||
bottom_left: line_origin + point(start.x, start.y + line_height),
|
||||
bottom_right: line_origin + point(end_x, start.y + line_height),
|
||||
});
|
||||
|
||||
// wrapped lines
|
||||
for i in 1..=wrapped_lines {
|
||||
let start = point(px(0.), start.y + i as f32 * line_height);
|
||||
let mut end = point(end.x, end.y + i as f32 * line_height);
|
||||
if i < wrapped_lines {
|
||||
end.x = line_size.width;
|
||||
}
|
||||
|
||||
line_corners.push(Corners {
|
||||
top_left: line_origin + point(start.x, start.y),
|
||||
top_right: line_origin + point(end.x, start.y),
|
||||
bottom_left: line_origin + point(start.x, start.y + line_height),
|
||||
bottom_right: line_origin + point(end.x, start.y + line_height),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if line_cursor_start.is_some() && line_cursor_end.is_some() {
|
||||
break;
|
||||
}
|
||||
|
||||
offset_y += line_size.height;
|
||||
// +1 for skip the last `\n`
|
||||
prev_lines_offset += line.len() + 1;
|
||||
}
|
||||
|
||||
let mut points = vec![];
|
||||
if line_corners.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Fix corners to make sure the left to right direction
|
||||
for corners in &mut line_corners {
|
||||
if corners.top_left.x > corners.top_right.x {
|
||||
std::mem::swap(&mut corners.top_left, &mut corners.top_right);
|
||||
std::mem::swap(&mut corners.bottom_left, &mut corners.bottom_right);
|
||||
}
|
||||
}
|
||||
|
||||
for corners in &line_corners {
|
||||
points.push(corners.top_right);
|
||||
points.push(corners.bottom_right);
|
||||
points.push(corners.bottom_left);
|
||||
}
|
||||
|
||||
let mut rev_line_corners = line_corners.iter().rev().peekable();
|
||||
while let Some(corners) = rev_line_corners.next() {
|
||||
points.push(corners.top_left);
|
||||
if let Some(next) = rev_line_corners.peek() {
|
||||
if next.top_left.x > corners.top_left.x {
|
||||
points.push(point(next.top_left.x, corners.top_left.y));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// print_points_as_svg_path(&line_corners, &points);
|
||||
|
||||
let first_p = *points.get(0).unwrap();
|
||||
let mut path = gpui::Path::new(bounds.origin + first_p);
|
||||
for p in points.iter().skip(1) {
|
||||
path.line_to(bounds.origin + *p);
|
||||
}
|
||||
Some(path)
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) struct PrepaintState {
|
||||
lines: SmallVec<[WrappedLine; 1]>,
|
||||
cursor: Option<PaintQuad>,
|
||||
cursor_scroll_offset: Point<Pixels>,
|
||||
selection_path: Option<Path<Pixels>>,
|
||||
bounds: Bounds<Pixels>,
|
||||
}
|
||||
|
||||
impl IntoElement for TextElement {
|
||||
type Element = Self;
|
||||
|
||||
fn into_element(self) -> Self::Element {
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// A debug function to print points as SVG path.
|
||||
#[allow(unused)]
|
||||
fn print_points_as_svg_path(
|
||||
line_corners: &Vec<Corners<Point<Pixels>>>,
|
||||
points: &Vec<Point<Pixels>>,
|
||||
) {
|
||||
for corners in line_corners {
|
||||
println!(
|
||||
"tl: ({}, {}), tr: ({}, {}), bl: ({}, {}), br: ({}, {})",
|
||||
corners.top_left.x.0 as i32,
|
||||
corners.top_left.y.0 as i32,
|
||||
corners.top_right.x.0 as i32,
|
||||
corners.top_right.y.0 as i32,
|
||||
corners.bottom_left.x.0 as i32,
|
||||
corners.bottom_left.y.0 as i32,
|
||||
corners.bottom_right.x.0 as i32,
|
||||
corners.bottom_right.y.0 as i32,
|
||||
);
|
||||
}
|
||||
|
||||
if points.len() > 0 {
|
||||
println!("M{},{}", points[0].x.0 as i32, points[0].y.0 as i32);
|
||||
for p in points.iter().skip(1) {
|
||||
println!("L{},{}", p.x.0 as i32, p.y.0 as i32);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Element for TextElement {
|
||||
type RequestLayoutState = ();
|
||||
type PrepaintState = PrepaintState;
|
||||
|
||||
fn id(&self) -> Option<ElementId> {
|
||||
None
|
||||
}
|
||||
|
||||
fn request_layout(
|
||||
&mut self,
|
||||
_id: Option<&GlobalElementId>,
|
||||
cx: &mut WindowContext,
|
||||
) -> (LayoutId, Self::RequestLayoutState) {
|
||||
let input = self.input.read(cx);
|
||||
let mut style = Style::default();
|
||||
style.size.width = relative(1.).into();
|
||||
if self.input.read(cx).is_multi_line() {
|
||||
style.size.height = relative(1.).into();
|
||||
style.min_size.height = (input.rows.max(1) as f32 * cx.line_height()).into();
|
||||
} else {
|
||||
style.size.height = cx.line_height().into();
|
||||
};
|
||||
(cx.request_layout(style, []), ())
|
||||
}
|
||||
|
||||
fn prepaint(
|
||||
&mut self,
|
||||
_id: Option<&GlobalElementId>,
|
||||
bounds: Bounds<Pixels>,
|
||||
_request_layout: &mut Self::RequestLayoutState,
|
||||
cx: &mut WindowContext,
|
||||
) -> Self::PrepaintState {
|
||||
let multi_line = self.input.read(cx).is_multi_line();
|
||||
let line_height = cx.line_height();
|
||||
let input = self.input.read(cx);
|
||||
let text = input.text.clone();
|
||||
let placeholder = input.placeholder.clone();
|
||||
let style = cx.text_style();
|
||||
let mut bounds = bounds;
|
||||
|
||||
let (display_text, text_color) = if text.is_empty() {
|
||||
(placeholder, cx.theme().muted_foreground)
|
||||
} else if input.masked {
|
||||
(
|
||||
"*".repeat(text.chars().count()).into(),
|
||||
cx.theme().foreground,
|
||||
)
|
||||
} else {
|
||||
(text, cx.theme().foreground)
|
||||
};
|
||||
|
||||
let run = TextRun {
|
||||
len: display_text.len(),
|
||||
font: style.font(),
|
||||
color: text_color,
|
||||
background_color: None,
|
||||
underline: None,
|
||||
strikethrough: None,
|
||||
};
|
||||
|
||||
let runs = if let Some(marked_range) = input.marked_range.as_ref() {
|
||||
vec![
|
||||
TextRun {
|
||||
len: marked_range.start,
|
||||
..run.clone()
|
||||
},
|
||||
TextRun {
|
||||
len: marked_range.end - marked_range.start,
|
||||
underline: Some(UnderlineStyle {
|
||||
color: Some(run.color),
|
||||
thickness: px(1.0),
|
||||
wavy: false,
|
||||
}),
|
||||
..run.clone()
|
||||
},
|
||||
TextRun {
|
||||
len: display_text.len() - marked_range.end,
|
||||
..run.clone()
|
||||
},
|
||||
]
|
||||
.into_iter()
|
||||
.filter(|run| run.len > 0)
|
||||
.collect()
|
||||
} else {
|
||||
vec![run]
|
||||
};
|
||||
|
||||
let font_size = style.font_size.to_pixels(cx.rem_size());
|
||||
let wrap_width = if multi_line {
|
||||
Some(bounds.size.width - RIGHT_MARGIN)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let lines = cx
|
||||
.text_system()
|
||||
.shape_text(display_text, font_size, &runs, wrap_width)
|
||||
.unwrap();
|
||||
|
||||
// `position_for_index` for example
|
||||
//
|
||||
// #### text
|
||||
//
|
||||
// Hello 世界,this is GPUI component.
|
||||
// The GPUI Component is a collection of UI components for
|
||||
// GPUI framework, including Button, Input, Checkbox, Radio,
|
||||
// Dropdown, Tab, and more...
|
||||
//
|
||||
// wrap_width: 444px, line_height: 20px
|
||||
//
|
||||
// #### lines[0]
|
||||
//
|
||||
// | index | pos | line |
|
||||
// |-------|------------------|------|
|
||||
// | 5 | (37 px, 0.0) | 0 |
|
||||
// | 38 | (261.7 px, 20.0) | 0 |
|
||||
// | 40 | None | - |
|
||||
//
|
||||
// #### lines[1]
|
||||
//
|
||||
// | index | position | line |
|
||||
// |-------|-----------------------|------|
|
||||
// | 5 | (43.578125 px, 0.0) | 0 |
|
||||
// | 56 | (422.21094 px, 0.0) | 0 |
|
||||
// | 57 | (11.6328125 px, 20.0) | 1 |
|
||||
// | 114 | (429.85938 px, 20.0) | 1 |
|
||||
// | 115 | (11.3125 px, 40.0) | 2 |
|
||||
|
||||
// Calculate the scroll offset to keep the cursor in view
|
||||
|
||||
let (cursor, cursor_scroll_offset) =
|
||||
self.layout_cursor(&lines, line_height, &mut bounds, cx);
|
||||
|
||||
let selection_path = self.layout_selections(&lines, line_height, &mut bounds, cx);
|
||||
|
||||
PrepaintState {
|
||||
bounds,
|
||||
lines,
|
||||
cursor,
|
||||
cursor_scroll_offset,
|
||||
selection_path,
|
||||
}
|
||||
}
|
||||
|
||||
fn paint(
|
||||
&mut self,
|
||||
_id: Option<&GlobalElementId>,
|
||||
input_bounds: Bounds<Pixels>,
|
||||
_request_layout: &mut Self::RequestLayoutState,
|
||||
prepaint: &mut Self::PrepaintState,
|
||||
cx: &mut WindowContext,
|
||||
) {
|
||||
let focus_handle = self.input.read(cx).focus_handle.clone();
|
||||
let focused = focus_handle.is_focused(cx);
|
||||
let bounds = prepaint.bounds;
|
||||
let selected_range = self.input.read(cx).selected_range.clone();
|
||||
|
||||
cx.handle_input(
|
||||
&focus_handle,
|
||||
ElementInputHandler::new(bounds, self.input.clone()),
|
||||
);
|
||||
|
||||
// Paint selections
|
||||
if let Some(path) = prepaint.selection_path.take() {
|
||||
cx.paint_path(path, cx.theme().selection);
|
||||
}
|
||||
|
||||
// Paint multi line text
|
||||
let line_height = cx.line_height();
|
||||
let origin = bounds.origin;
|
||||
|
||||
let mut offset_y = px(0.);
|
||||
for line in prepaint.lines.iter() {
|
||||
let p = point(origin.x, origin.y + offset_y);
|
||||
_ = line.paint(p, line_height, cx);
|
||||
offset_y += line.size(line_height).height;
|
||||
}
|
||||
|
||||
if focused {
|
||||
if let Some(cursor) = prepaint.cursor.take() {
|
||||
cx.paint_quad(cursor);
|
||||
}
|
||||
}
|
||||
|
||||
let width = prepaint
|
||||
.lines
|
||||
.iter()
|
||||
.map(|l| l.width())
|
||||
.max()
|
||||
.unwrap_or_default();
|
||||
let height = prepaint
|
||||
.lines
|
||||
.iter()
|
||||
.map(|l| l.size(line_height).height.0)
|
||||
.sum::<f32>();
|
||||
|
||||
let scroll_size = size(width, px(height));
|
||||
|
||||
self.input.update(cx, |input, _cx| {
|
||||
input.last_layout = Some(prepaint.lines.clone());
|
||||
input.last_bounds = Some(bounds);
|
||||
input.last_cursor_offset = Some(input.cursor_offset());
|
||||
input.last_line_height = line_height;
|
||||
input.input_bounds = input_bounds;
|
||||
input.last_selected_range = Some(selected_range);
|
||||
input
|
||||
.scroll_handle
|
||||
.set_offset(prepaint.cursor_scroll_offset);
|
||||
input.scroll_size = scroll_size;
|
||||
});
|
||||
|
||||
self.paint_mouse_listeners(cx);
|
||||
}
|
||||
}
|
||||
1269
crates/ui/src/input/input.rs
Normal file
1269
crates/ui/src/input/input.rs
Normal file
File diff suppressed because it is too large
Load Diff
10
crates/ui/src/input/mod.rs
Normal file
10
crates/ui/src/input/mod.rs
Normal file
@@ -0,0 +1,10 @@
|
||||
mod blink_cursor;
|
||||
mod change;
|
||||
mod clear_button;
|
||||
mod element;
|
||||
mod input;
|
||||
mod otp_input;
|
||||
|
||||
pub(crate) use clear_button::*;
|
||||
pub use input::*;
|
||||
pub use otp_input::*;
|
||||
274
crates/ui/src/input/otp_input.rs
Normal file
274
crates/ui/src/input/otp_input.rs
Normal file
@@ -0,0 +1,274 @@
|
||||
use gpui::{
|
||||
div, prelude::FluentBuilder, px, AnyElement, Context, EventEmitter, FocusHandle, FocusableView,
|
||||
InteractiveElement, IntoElement, KeyDownEvent, Model, MouseButton, MouseDownEvent,
|
||||
ParentElement as _, Render, SharedString, Styled as _, ViewContext,
|
||||
};
|
||||
|
||||
use crate::{h_flex, theme::ActiveTheme, v_flex, Icon, IconName, Sizable, Size};
|
||||
|
||||
use super::{blink_cursor::BlinkCursor, InputEvent};
|
||||
|
||||
pub enum InputOptEvent {
|
||||
/// When all OTP input have filled, this event will be triggered.
|
||||
Change(SharedString),
|
||||
}
|
||||
|
||||
/// A One Time Password (OTP) input element.
|
||||
///
|
||||
/// This can accept a fixed length number and can be masked.
|
||||
///
|
||||
/// Use case example:
|
||||
///
|
||||
/// - SMS OTP
|
||||
/// - Authenticator OTP
|
||||
pub struct OtpInput {
|
||||
focus_handle: FocusHandle,
|
||||
length: usize,
|
||||
number_of_groups: usize,
|
||||
masked: bool,
|
||||
value: SharedString,
|
||||
blink_cursor: Model<BlinkCursor>,
|
||||
size: Size,
|
||||
}
|
||||
|
||||
impl OtpInput {
|
||||
pub fn new(length: usize, cx: &mut ViewContext<Self>) -> Self {
|
||||
let focus_handle = cx.focus_handle();
|
||||
let blink_cursor = cx.new_model(|_| BlinkCursor::new());
|
||||
let input = Self {
|
||||
focus_handle: focus_handle.clone(),
|
||||
length,
|
||||
number_of_groups: 2,
|
||||
value: SharedString::default(),
|
||||
masked: false,
|
||||
blink_cursor: blink_cursor.clone(),
|
||||
size: Size::Medium,
|
||||
};
|
||||
|
||||
// Observe the blink cursor to repaint the view when it changes.
|
||||
cx.observe(&blink_cursor, |_, _, cx| cx.notify()).detach();
|
||||
// Blink the cursor when the window is active, pause when it's not.
|
||||
cx.observe_window_activation(|this, cx| {
|
||||
if cx.is_window_active() {
|
||||
let focus_handle = this.focus_handle.clone();
|
||||
if focus_handle.is_focused(cx) {
|
||||
this.blink_cursor.update(cx, |blink_cursor, cx| {
|
||||
blink_cursor.start(cx);
|
||||
});
|
||||
}
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
|
||||
cx.on_focus(&focus_handle, Self::on_focus).detach();
|
||||
cx.on_blur(&focus_handle, Self::on_blur).detach();
|
||||
|
||||
input
|
||||
}
|
||||
|
||||
/// Set number of groups in the OTP Input.
|
||||
pub fn groups(mut self, n: usize) -> Self {
|
||||
self.number_of_groups = n;
|
||||
self
|
||||
}
|
||||
|
||||
/// Set default value of the OTP Input.
|
||||
pub fn default_value(mut self, value: impl Into<SharedString>) -> Self {
|
||||
self.value = value.into();
|
||||
self
|
||||
}
|
||||
|
||||
/// Set value of the OTP Input.
|
||||
pub fn set_value(&mut self, value: impl Into<SharedString>, cx: &mut ViewContext<Self>) {
|
||||
self.value = value.into();
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Return the value of the OTP Input.
|
||||
pub fn value(&self) -> SharedString {
|
||||
self.value.clone()
|
||||
}
|
||||
|
||||
/// Set masked to true use masked input.
|
||||
pub fn masked(mut self, masked: bool) -> Self {
|
||||
self.masked = masked;
|
||||
self
|
||||
}
|
||||
|
||||
/// Set masked to true use masked input.
|
||||
pub fn set_masked(&mut self, masked: bool, cx: &mut ViewContext<Self>) {
|
||||
self.masked = masked;
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub fn focus(&self, cx: &mut ViewContext<Self>) {
|
||||
self.focus_handle.focus(cx);
|
||||
}
|
||||
|
||||
fn on_input_mouse_down(&mut self, _: &MouseDownEvent, cx: &mut ViewContext<Self>) {
|
||||
cx.focus(&self.focus_handle);
|
||||
}
|
||||
|
||||
fn on_key_down(&mut self, event: &KeyDownEvent, cx: &mut ViewContext<Self>) {
|
||||
let mut chars: Vec<char> = self.value.chars().collect();
|
||||
let ix = chars.len();
|
||||
|
||||
let key = event.keystroke.key.as_str();
|
||||
|
||||
match key {
|
||||
"backspace" => {
|
||||
if ix > 0 {
|
||||
let ix = ix - 1;
|
||||
chars.remove(ix);
|
||||
}
|
||||
|
||||
cx.prevent_default();
|
||||
cx.stop_propagation();
|
||||
}
|
||||
_ => {
|
||||
let c = key.chars().next().unwrap();
|
||||
if !matches!(c, '0'..='9') {
|
||||
return;
|
||||
}
|
||||
if ix >= self.length {
|
||||
return;
|
||||
}
|
||||
|
||||
chars.push(c);
|
||||
|
||||
cx.prevent_default();
|
||||
cx.stop_propagation();
|
||||
}
|
||||
}
|
||||
|
||||
self.pause_blink_cursor(cx);
|
||||
self.value = SharedString::from(chars.iter().collect::<String>());
|
||||
|
||||
if self.value.chars().count() == self.length {
|
||||
cx.emit(InputEvent::Change(self.value.clone()));
|
||||
}
|
||||
cx.notify()
|
||||
}
|
||||
|
||||
fn on_focus(&mut self, cx: &mut ViewContext<Self>) {
|
||||
self.blink_cursor.update(cx, |cursor, cx| {
|
||||
cursor.start(cx);
|
||||
});
|
||||
cx.emit(InputEvent::Focus);
|
||||
}
|
||||
|
||||
fn on_blur(&mut self, cx: &mut ViewContext<Self>) {
|
||||
self.blink_cursor.update(cx, |cursor, cx| {
|
||||
cursor.stop(cx);
|
||||
});
|
||||
cx.emit(InputEvent::Blur);
|
||||
}
|
||||
|
||||
fn pause_blink_cursor(&mut self, cx: &mut ViewContext<Self>) {
|
||||
self.blink_cursor.update(cx, |cursor, cx| {
|
||||
cursor.pause(cx);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
impl Sizable for OtpInput {
|
||||
fn with_size(mut self, size: impl Into<crate::Size>) -> Self {
|
||||
self.size = size.into();
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl FocusableView for OtpInput {
|
||||
fn focus_handle(&self, _: &gpui::AppContext) -> FocusHandle {
|
||||
self.focus_handle.clone()
|
||||
}
|
||||
}
|
||||
impl EventEmitter<InputEvent> for OtpInput {}
|
||||
|
||||
impl Render for OtpInput {
|
||||
fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
|
||||
let blink_show = self.blink_cursor.read(cx).visible();
|
||||
let is_focused = self.focus_handle.is_focused(cx);
|
||||
|
||||
let text_size = match self.size {
|
||||
Size::XSmall => px(14.),
|
||||
Size::Small => px(14.),
|
||||
Size::Medium => px(16.),
|
||||
Size::Large => px(18.),
|
||||
Size::Size(v) => v * 0.5,
|
||||
};
|
||||
|
||||
let mut groups: Vec<Vec<AnyElement>> = Vec::with_capacity(self.number_of_groups);
|
||||
let mut group_ix = 0;
|
||||
let group_items_count = self.length / self.number_of_groups;
|
||||
for _ in 0..self.number_of_groups {
|
||||
groups.push(vec![]);
|
||||
}
|
||||
|
||||
for i in 0..self.length {
|
||||
let c = self.value.chars().nth(i);
|
||||
if i % group_items_count == 0 && i != 0 {
|
||||
group_ix += 1;
|
||||
}
|
||||
|
||||
let is_input_focused = i == self.value.chars().count() && is_focused;
|
||||
|
||||
groups[group_ix].push(
|
||||
h_flex()
|
||||
.id(("input-otp", i))
|
||||
.border_1()
|
||||
.border_color(cx.theme().input)
|
||||
.bg(cx.theme().background)
|
||||
.when(is_input_focused, |this| this.border_color(cx.theme().ring))
|
||||
.when(cx.theme().shadow, |this| this.shadow_sm())
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.rounded_md()
|
||||
.text_size(text_size)
|
||||
.map(|this| match self.size {
|
||||
Size::XSmall => this.w_6().h_6(),
|
||||
Size::Small => this.w_6().h_6(),
|
||||
Size::Medium => this.w_8().h_8(),
|
||||
Size::Large => this.w_11().h_11(),
|
||||
Size::Size(px) => this.w(px).h(px),
|
||||
})
|
||||
.on_mouse_down(MouseButton::Left, cx.listener(Self::on_input_mouse_down))
|
||||
.map(|this| match c {
|
||||
Some(c) => {
|
||||
if self.masked {
|
||||
this.child(
|
||||
Icon::new(IconName::Asterisk)
|
||||
.text_color(cx.theme().secondary_foreground)
|
||||
.with_size(text_size),
|
||||
)
|
||||
} else {
|
||||
this.child(c.to_string())
|
||||
}
|
||||
}
|
||||
None => this.when(is_input_focused && blink_show, |this| {
|
||||
this.child(
|
||||
div()
|
||||
.h_4()
|
||||
.w_0()
|
||||
.border_l_3()
|
||||
.border_color(crate::blue_500()),
|
||||
)
|
||||
}),
|
||||
})
|
||||
.into_any_element(),
|
||||
);
|
||||
}
|
||||
|
||||
v_flex()
|
||||
.track_focus(&self.focus_handle)
|
||||
.on_key_down(cx.listener(Self::on_key_down))
|
||||
.items_center()
|
||||
.child(
|
||||
h_flex().items_center().gap_5().children(
|
||||
groups
|
||||
.into_iter()
|
||||
.map(|inputs| h_flex().items_center().gap_1().children(inputs)),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
94
crates/ui/src/label.rs
Normal file
94
crates/ui/src/label.rs
Normal file
@@ -0,0 +1,94 @@
|
||||
use gpui::{
|
||||
div, prelude::FluentBuilder, rems, Div, IntoElement, ParentElement, RenderOnce, SharedString,
|
||||
Styled, WindowContext,
|
||||
};
|
||||
|
||||
use crate::{h_flex, theme::ActiveTheme};
|
||||
|
||||
const MASKED: &str = "•";
|
||||
|
||||
#[derive(Default, PartialEq, Eq)]
|
||||
pub enum TextAlign {
|
||||
#[default]
|
||||
Left,
|
||||
Center,
|
||||
Right,
|
||||
}
|
||||
|
||||
#[derive(IntoElement)]
|
||||
pub struct Label {
|
||||
base: Div,
|
||||
label: SharedString,
|
||||
align: TextAlign,
|
||||
marked: bool,
|
||||
}
|
||||
|
||||
impl Label {
|
||||
pub fn new(label: impl Into<SharedString>) -> Self {
|
||||
Self {
|
||||
base: h_flex().line_height(rems(1.25)),
|
||||
label: label.into(),
|
||||
align: TextAlign::default(),
|
||||
marked: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn text_align(mut self, align: TextAlign) -> Self {
|
||||
self.align = align;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn text_left(mut self) -> Self {
|
||||
self.align = TextAlign::Left;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn text_center(mut self) -> Self {
|
||||
self.align = TextAlign::Center;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn text_right(mut self) -> Self {
|
||||
self.align = TextAlign::Right;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn masked(mut self, masked: bool) -> Self {
|
||||
self.marked = masked;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl Styled for Label {
|
||||
fn style(&mut self) -> &mut gpui::StyleRefinement {
|
||||
self.base.style()
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for Label {
|
||||
fn render(self, cx: &mut WindowContext) -> impl IntoElement {
|
||||
let text = self.label;
|
||||
|
||||
let text_display = if self.marked {
|
||||
MASKED.repeat(text.chars().count())
|
||||
} else {
|
||||
text.to_string()
|
||||
};
|
||||
|
||||
div().text_color(cx.theme().foreground).child(
|
||||
self.base
|
||||
.map(|this| match self.align {
|
||||
TextAlign::Left => this.justify_start(),
|
||||
TextAlign::Center => this.justify_center(),
|
||||
TextAlign::Right => this.justify_end(),
|
||||
})
|
||||
.map(|this| {
|
||||
if self.align == TextAlign::Left {
|
||||
this.child(div().size_full().child(text_display))
|
||||
} else {
|
||||
this.child(text_display)
|
||||
}
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1 +1,82 @@
|
||||
mod colors;
|
||||
mod event;
|
||||
mod focusable;
|
||||
mod icon;
|
||||
mod root;
|
||||
mod styled;
|
||||
mod svg_img;
|
||||
mod title_bar;
|
||||
|
||||
pub mod accordion;
|
||||
pub mod animation;
|
||||
pub mod badge;
|
||||
pub mod breadcrumb;
|
||||
pub mod button;
|
||||
pub mod button_group;
|
||||
pub mod checkbox;
|
||||
pub mod clipboard;
|
||||
pub mod color_picker;
|
||||
pub mod context_menu;
|
||||
pub mod divider;
|
||||
pub mod dock;
|
||||
pub mod drawer;
|
||||
pub mod dropdown;
|
||||
pub mod history;
|
||||
pub mod indicator;
|
||||
pub mod input;
|
||||
pub mod label;
|
||||
pub mod link;
|
||||
pub mod list;
|
||||
pub mod modal;
|
||||
pub mod notification;
|
||||
pub mod number_input;
|
||||
pub mod popover;
|
||||
pub mod popup_menu;
|
||||
pub mod prelude;
|
||||
pub mod progress;
|
||||
pub mod radio;
|
||||
pub mod resizable;
|
||||
pub mod scroll;
|
||||
pub mod sidebar;
|
||||
pub mod skeleton;
|
||||
pub mod slider;
|
||||
pub mod switch;
|
||||
pub mod tab;
|
||||
pub mod table;
|
||||
pub mod theme;
|
||||
pub mod tooltip;
|
||||
|
||||
pub use crate::Disableable;
|
||||
pub use event::InteractiveElementExt;
|
||||
pub use focusable::FocusableCycle;
|
||||
pub use root::{ContextModal, Root};
|
||||
pub use styled::*;
|
||||
pub use title_bar::*;
|
||||
|
||||
pub use colors::*;
|
||||
pub use icon::*;
|
||||
pub use svg_img::*;
|
||||
|
||||
use rust_embed::RustEmbed;
|
||||
|
||||
#[derive(RustEmbed)]
|
||||
#[folder = "../../assets"]
|
||||
pub struct Assets;
|
||||
|
||||
/// Initialize the UI module.
|
||||
///
|
||||
/// This must be called before using any of the UI components.
|
||||
/// You can initialize the UI module at your application's entry point.
|
||||
pub fn init(cx: &mut gpui::AppContext) {
|
||||
theme::init(cx);
|
||||
dock::init(cx);
|
||||
drawer::init(cx);
|
||||
dropdown::init(cx);
|
||||
input::init(cx);
|
||||
number_input::init(cx);
|
||||
list::init(cx);
|
||||
modal::init(cx);
|
||||
popover::init(cx);
|
||||
popup_menu::init(cx);
|
||||
table::init(cx);
|
||||
}
|
||||
|
||||
93
crates/ui/src/link.rs
Normal file
93
crates/ui/src/link.rs
Normal file
@@ -0,0 +1,93 @@
|
||||
use gpui::{
|
||||
div, ClickEvent, Div, ElementId, InteractiveElement, IntoElement, MouseButton, ParentElement,
|
||||
RenderOnce, SharedString, Stateful, StatefulInteractiveElement, Styled,
|
||||
};
|
||||
|
||||
use crate::theme::ActiveTheme as _;
|
||||
|
||||
/// A Link element like a `<a>` tag in HTML.
|
||||
#[derive(IntoElement)]
|
||||
pub struct Link {
|
||||
base: Stateful<Div>,
|
||||
href: Option<SharedString>,
|
||||
disabled: bool,
|
||||
on_click: Option<Box<dyn Fn(&ClickEvent, &mut gpui::WindowContext) + 'static>>,
|
||||
}
|
||||
|
||||
impl Link {
|
||||
pub fn new(id: impl Into<ElementId>) -> Self {
|
||||
Self {
|
||||
base: div().id(id),
|
||||
href: None,
|
||||
on_click: None,
|
||||
disabled: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn href(mut self, href: impl Into<SharedString>) -> Self {
|
||||
self.href = Some(href.into());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn on_click(
|
||||
mut self,
|
||||
handler: impl Fn(&ClickEvent, &mut gpui::WindowContext) + 'static,
|
||||
) -> Self {
|
||||
self.on_click = Some(Box::new(handler));
|
||||
self
|
||||
}
|
||||
|
||||
pub fn disabled(mut self, disabled: bool) -> Self {
|
||||
self.disabled = disabled;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl Styled for Link {
|
||||
fn style(&mut self) -> &mut gpui::StyleRefinement {
|
||||
self.base.style()
|
||||
}
|
||||
}
|
||||
|
||||
impl ParentElement for Link {
|
||||
fn extend(&mut self, elements: impl IntoIterator<Item = gpui::AnyElement>) {
|
||||
self.base.extend(elements)
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for Link {
|
||||
fn render(self, cx: &mut gpui::WindowContext) -> impl IntoElement {
|
||||
let href = self.href.clone();
|
||||
let on_click = self.on_click;
|
||||
|
||||
div()
|
||||
.text_color(cx.theme().link)
|
||||
.text_decoration_1()
|
||||
.text_decoration_color(cx.theme().link)
|
||||
.hover(|this| {
|
||||
this.text_color(cx.theme().link.opacity(0.8))
|
||||
.text_decoration_1()
|
||||
})
|
||||
.cursor_pointer()
|
||||
.child(
|
||||
self.base
|
||||
.active(|this| {
|
||||
this.text_color(cx.theme().link.opacity(0.6))
|
||||
.text_decoration_1()
|
||||
})
|
||||
.on_mouse_down(MouseButton::Left, |_, cx| {
|
||||
cx.stop_propagation();
|
||||
})
|
||||
.on_click({
|
||||
move |e, cx| {
|
||||
if let Some(href) = &href {
|
||||
cx.open_url(&href.clone());
|
||||
}
|
||||
if let Some(on_click) = &on_click {
|
||||
on_click(e, cx);
|
||||
}
|
||||
}
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
465
crates/ui/src/list/list.rs
Normal file
465
crates/ui/src/list/list.rs
Normal file
@@ -0,0 +1,465 @@
|
||||
use std::time::Duration;
|
||||
use std::{cell::Cell, rc::Rc};
|
||||
|
||||
use crate::Icon;
|
||||
use crate::{
|
||||
input::{InputEvent, TextInput},
|
||||
scroll::{Scrollbar, ScrollbarState},
|
||||
theme::ActiveTheme,
|
||||
v_flex, IconName, Size,
|
||||
};
|
||||
use gpui::{
|
||||
actions, div, prelude::FluentBuilder, uniform_list, AnyElement, AppContext, Entity,
|
||||
FocusHandle, FocusableView, InteractiveElement, IntoElement, KeyBinding, Length,
|
||||
ListSizingBehavior, MouseButton, ParentElement, Render, SharedString, Styled, Task,
|
||||
UniformListScrollHandle, View, ViewContext, VisualContext, WindowContext,
|
||||
};
|
||||
use gpui::{px, ScrollStrategy};
|
||||
use smol::Timer;
|
||||
|
||||
actions!(list, [Cancel, Confirm, SelectPrev, SelectNext]);
|
||||
|
||||
pub fn init(cx: &mut AppContext) {
|
||||
let context: Option<&str> = Some("List");
|
||||
cx.bind_keys([
|
||||
KeyBinding::new("escape", Cancel, context),
|
||||
KeyBinding::new("enter", Confirm, context),
|
||||
KeyBinding::new("up", SelectPrev, context),
|
||||
KeyBinding::new("down", SelectNext, context),
|
||||
]);
|
||||
}
|
||||
|
||||
/// A delegate for the List.
|
||||
#[allow(unused)]
|
||||
pub trait ListDelegate: Sized + 'static {
|
||||
type Item: IntoElement;
|
||||
|
||||
/// When Query Input change, this method will be called.
|
||||
/// You can perform search here.
|
||||
fn perform_search(&mut self, query: &str, cx: &mut ViewContext<List<Self>>) -> Task<()> {
|
||||
Task::Ready(Some(()))
|
||||
}
|
||||
|
||||
/// Return the number of items in the list.
|
||||
fn items_count(&self, cx: &AppContext) -> usize;
|
||||
|
||||
/// Render the item at the given index.
|
||||
///
|
||||
/// Return None will skip the item.
|
||||
fn render_item(&self, ix: usize, cx: &mut ViewContext<List<Self>>) -> Option<Self::Item>;
|
||||
|
||||
/// Return a Element to show when list is empty.
|
||||
fn render_empty(&self, cx: &mut ViewContext<List<Self>>) -> impl IntoElement {
|
||||
div()
|
||||
}
|
||||
|
||||
/// Returns Some(AnyElement) to render the initial state of the list.
|
||||
///
|
||||
/// This can be used to show a view for the list before the user has interacted with it.
|
||||
///
|
||||
/// For example: The last search results, or the last selected item.
|
||||
///
|
||||
/// Default is None, that means no initial state.
|
||||
fn render_initial(&self, cx: &mut ViewContext<List<Self>>) -> Option<AnyElement> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Return the confirmed index of the selected item.
|
||||
fn confirmed_index(&self, cx: &AppContext) -> Option<usize> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Set the selected index, just store the ix, don't confirm.
|
||||
fn set_selected_index(&mut self, ix: Option<usize>, cx: &mut ViewContext<List<Self>>);
|
||||
|
||||
/// Set the confirm and give the selected index, this is means user have clicked the item or pressed Enter.
|
||||
fn confirm(&mut self, ix: Option<usize>, cx: &mut ViewContext<List<Self>>) {}
|
||||
|
||||
/// Cancel the selection, e.g.: Pressed ESC.
|
||||
fn cancel(&mut self, cx: &mut ViewContext<List<Self>>) {}
|
||||
}
|
||||
|
||||
pub struct List<D: ListDelegate> {
|
||||
focus_handle: FocusHandle,
|
||||
delegate: D,
|
||||
max_height: Option<Length>,
|
||||
query_input: Option<View<TextInput>>,
|
||||
last_query: Option<String>,
|
||||
loading: bool,
|
||||
|
||||
enable_scrollbar: bool,
|
||||
vertical_scroll_handle: UniformListScrollHandle,
|
||||
scrollbar_state: Rc<Cell<ScrollbarState>>,
|
||||
|
||||
pub(crate) size: Size,
|
||||
selected_index: Option<usize>,
|
||||
right_clicked_index: Option<usize>,
|
||||
_search_task: Task<()>,
|
||||
}
|
||||
|
||||
impl<D> List<D>
|
||||
where
|
||||
D: ListDelegate,
|
||||
{
|
||||
pub fn new(delegate: D, cx: &mut ViewContext<Self>) -> Self {
|
||||
let query_input = cx.new_view(|cx| {
|
||||
TextInput::new(cx)
|
||||
.appearance(false)
|
||||
.prefix(|cx| Icon::new(IconName::Search).text_color(cx.theme().muted_foreground))
|
||||
.placeholder("Search...")
|
||||
.cleanable()
|
||||
});
|
||||
|
||||
cx.subscribe(&query_input, Self::on_query_input_event)
|
||||
.detach();
|
||||
|
||||
Self {
|
||||
focus_handle: cx.focus_handle(),
|
||||
delegate,
|
||||
query_input: Some(query_input),
|
||||
last_query: None,
|
||||
selected_index: None,
|
||||
right_clicked_index: None,
|
||||
vertical_scroll_handle: UniformListScrollHandle::new(),
|
||||
scrollbar_state: Rc::new(Cell::new(ScrollbarState::new())),
|
||||
max_height: None,
|
||||
enable_scrollbar: true,
|
||||
loading: false,
|
||||
size: Size::default(),
|
||||
_search_task: Task::Ready(None),
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the size
|
||||
pub fn set_size(&mut self, size: Size, cx: &mut ViewContext<Self>) {
|
||||
if let Some(input) = &self.query_input {
|
||||
input.update(cx, |input, cx| {
|
||||
input.set_size(size, cx);
|
||||
})
|
||||
}
|
||||
self.size = size;
|
||||
}
|
||||
|
||||
pub fn max_h(mut self, height: impl Into<Length>) -> Self {
|
||||
self.max_height = Some(height.into());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn no_scrollbar(mut self) -> Self {
|
||||
self.enable_scrollbar = false;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn no_query(mut self) -> Self {
|
||||
self.query_input = None;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn set_query_input(&mut self, query_input: View<TextInput>, cx: &mut ViewContext<Self>) {
|
||||
cx.subscribe(&query_input, Self::on_query_input_event)
|
||||
.detach();
|
||||
self.query_input = Some(query_input);
|
||||
}
|
||||
|
||||
pub fn delegate(&self) -> &D {
|
||||
&self.delegate
|
||||
}
|
||||
|
||||
pub fn delegate_mut(&mut self) -> &mut D {
|
||||
&mut self.delegate
|
||||
}
|
||||
|
||||
pub fn focus(&mut self, cx: &mut WindowContext) {
|
||||
self.focus_handle(cx).focus(cx);
|
||||
}
|
||||
|
||||
pub fn set_selected_index(&mut self, ix: Option<usize>, cx: &mut ViewContext<Self>) {
|
||||
self.selected_index = ix;
|
||||
self.delegate.set_selected_index(ix, cx);
|
||||
}
|
||||
|
||||
pub fn selected_index(&self) -> Option<usize> {
|
||||
self.selected_index
|
||||
}
|
||||
|
||||
/// Set the query_input text
|
||||
pub fn set_query(&mut self, query: &str, cx: &mut ViewContext<Self>) {
|
||||
if let Some(query_input) = &self.query_input {
|
||||
let query = query.to_owned();
|
||||
query_input.update(cx, |input, cx| input.set_text(query, cx))
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the query_input text
|
||||
pub fn query(&self, cx: &mut ViewContext<Self>) -> Option<SharedString> {
|
||||
self.query_input.as_ref().map(|input| input.read(cx).text())
|
||||
}
|
||||
|
||||
fn render_scrollbar(&self, cx: &mut ViewContext<Self>) -> Option<impl IntoElement> {
|
||||
if !self.enable_scrollbar {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(Scrollbar::uniform_scroll(
|
||||
cx.view().entity_id(),
|
||||
self.scrollbar_state.clone(),
|
||||
self.vertical_scroll_handle.clone(),
|
||||
))
|
||||
}
|
||||
|
||||
fn scroll_to_selected_item(&mut self, _cx: &mut ViewContext<Self>) {
|
||||
if let Some(ix) = self.selected_index {
|
||||
self.vertical_scroll_handle
|
||||
.scroll_to_item(ix, ScrollStrategy::Top);
|
||||
}
|
||||
}
|
||||
|
||||
fn on_query_input_event(
|
||||
&mut self,
|
||||
_: View<TextInput>,
|
||||
event: &InputEvent,
|
||||
cx: &mut ViewContext<Self>,
|
||||
) {
|
||||
match event {
|
||||
InputEvent::Change(text) => {
|
||||
let text = text.trim().to_string();
|
||||
if Some(&text) == self.last_query.as_ref() {
|
||||
return;
|
||||
}
|
||||
|
||||
self.set_loading(true, cx);
|
||||
let search = self.delegate.perform_search(&text, cx);
|
||||
|
||||
self._search_task = cx.spawn(|this, mut cx| async move {
|
||||
search.await;
|
||||
|
||||
let _ = this.update(&mut cx, |this, _| {
|
||||
this.vertical_scroll_handle
|
||||
.scroll_to_item(0, ScrollStrategy::Top);
|
||||
this.last_query = Some(text);
|
||||
});
|
||||
|
||||
// Always wait 100ms to avoid flicker
|
||||
Timer::after(Duration::from_millis(100)).await;
|
||||
let _ = this.update(&mut cx, |this, cx| {
|
||||
this.set_loading(false, cx);
|
||||
});
|
||||
});
|
||||
}
|
||||
InputEvent::PressEnter => self.on_action_confirm(&Confirm, cx),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn set_loading(&mut self, loading: bool, cx: &mut ViewContext<Self>) {
|
||||
self.loading = loading;
|
||||
if let Some(input) = &self.query_input {
|
||||
input.update(cx, |input, cx| input.set_loading(loading, cx))
|
||||
}
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn on_action_cancel(&mut self, _: &Cancel, cx: &mut ViewContext<Self>) {
|
||||
self.set_selected_index(None, cx);
|
||||
self.delegate.cancel(cx);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn on_action_confirm(&mut self, _: &Confirm, cx: &mut ViewContext<Self>) {
|
||||
if self.delegate.items_count(cx) == 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
self.delegate.confirm(self.selected_index, cx);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn on_action_select_prev(&mut self, _: &SelectPrev, cx: &mut ViewContext<Self>) {
|
||||
if self.delegate.items_count(cx) == 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
let selected_index = self.selected_index.unwrap_or(0);
|
||||
if selected_index > 0 {
|
||||
self.selected_index = Some(selected_index - 1);
|
||||
} else {
|
||||
self.selected_index = Some(self.delegate.items_count(cx) - 1);
|
||||
}
|
||||
|
||||
self.delegate.set_selected_index(self.selected_index, cx);
|
||||
self.scroll_to_selected_item(cx);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn on_action_select_next(&mut self, _: &SelectNext, cx: &mut ViewContext<Self>) {
|
||||
if self.delegate.items_count(cx) == 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
if let Some(selected_index) = self.selected_index {
|
||||
if selected_index < self.delegate.items_count(cx) - 1 {
|
||||
self.selected_index = Some(selected_index + 1);
|
||||
} else {
|
||||
self.selected_index = Some(0);
|
||||
}
|
||||
} else {
|
||||
self.selected_index = Some(0);
|
||||
}
|
||||
|
||||
self.delegate.set_selected_index(self.selected_index, cx);
|
||||
self.scroll_to_selected_item(cx);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn render_list_item(&mut self, ix: usize, cx: &mut ViewContext<Self>) -> impl IntoElement {
|
||||
div()
|
||||
.id("list-item")
|
||||
.w_full()
|
||||
.relative()
|
||||
.children(self.delegate.render_item(ix, cx))
|
||||
.when_some(self.selected_index, |this, selected_index| {
|
||||
this.when(ix == selected_index, |this| {
|
||||
this.child(
|
||||
div()
|
||||
.absolute()
|
||||
.top(px(0.))
|
||||
.left(px(0.))
|
||||
.right(px(0.))
|
||||
.bottom(px(0.))
|
||||
.bg(cx.theme().list_active)
|
||||
.border_1()
|
||||
.border_color(cx.theme().list_active_border),
|
||||
)
|
||||
})
|
||||
})
|
||||
.when(self.right_clicked_index == Some(ix), |this| {
|
||||
this.child(
|
||||
div()
|
||||
.absolute()
|
||||
.top(px(0.))
|
||||
.left(px(0.))
|
||||
.right(px(0.))
|
||||
.bottom(px(0.))
|
||||
.border_1()
|
||||
.border_color(cx.theme().list_active_border),
|
||||
)
|
||||
})
|
||||
.on_mouse_down(
|
||||
MouseButton::Left,
|
||||
cx.listener(move |this, _, cx| {
|
||||
this.right_clicked_index = None;
|
||||
this.selected_index = Some(ix);
|
||||
this.on_action_confirm(&Confirm, cx);
|
||||
}),
|
||||
)
|
||||
.on_mouse_down(
|
||||
MouseButton::Right,
|
||||
cx.listener(move |this, _, cx| {
|
||||
this.right_clicked_index = Some(ix);
|
||||
cx.notify();
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl<D> FocusableView for List<D>
|
||||
where
|
||||
D: ListDelegate,
|
||||
{
|
||||
fn focus_handle(&self, cx: &AppContext) -> FocusHandle {
|
||||
if let Some(query_input) = &self.query_input {
|
||||
query_input.focus_handle(cx)
|
||||
} else {
|
||||
self.focus_handle.clone()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<D> Render for List<D>
|
||||
where
|
||||
D: ListDelegate,
|
||||
{
|
||||
fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
|
||||
let view = cx.view().clone();
|
||||
let vertical_scroll_handle = self.vertical_scroll_handle.clone();
|
||||
let items_count = self.delegate.items_count(cx);
|
||||
let sizing_behavior = if self.max_height.is_some() {
|
||||
ListSizingBehavior::Infer
|
||||
} else {
|
||||
ListSizingBehavior::Auto
|
||||
};
|
||||
|
||||
let initial_view = if let Some(input) = &self.query_input {
|
||||
if input.read(cx).text().is_empty() {
|
||||
self.delegate().render_initial(cx)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
v_flex()
|
||||
.key_context("List")
|
||||
.id("list")
|
||||
.track_focus(&self.focus_handle)
|
||||
.size_full()
|
||||
.relative()
|
||||
.overflow_hidden()
|
||||
.on_action(cx.listener(Self::on_action_cancel))
|
||||
.on_action(cx.listener(Self::on_action_confirm))
|
||||
.on_action(cx.listener(Self::on_action_select_next))
|
||||
.on_action(cx.listener(Self::on_action_select_prev))
|
||||
.when_some(self.query_input.clone(), |this, input| {
|
||||
this.child(
|
||||
div()
|
||||
.map(|this| match self.size {
|
||||
Size::Small => this.py_0().px_1p5(),
|
||||
_ => this.py_1().px_2(),
|
||||
})
|
||||
.border_b_1()
|
||||
.border_color(cx.theme().border)
|
||||
.child(input),
|
||||
)
|
||||
})
|
||||
.map(|this| {
|
||||
if let Some(view) = initial_view {
|
||||
this.child(view)
|
||||
} else {
|
||||
this.child(
|
||||
v_flex()
|
||||
.flex_grow()
|
||||
.relative()
|
||||
.when_some(self.max_height, |this, h| this.max_h(h))
|
||||
.overflow_hidden()
|
||||
.when(items_count == 0, |this| {
|
||||
this.child(self.delegate().render_empty(cx))
|
||||
})
|
||||
.when(items_count > 0, |this| {
|
||||
this.child(
|
||||
uniform_list(view, "uniform-list", items_count, {
|
||||
move |list, visible_range, cx| {
|
||||
visible_range
|
||||
.map(|ix| list.render_list_item(ix, cx))
|
||||
.collect::<Vec<_>>()
|
||||
}
|
||||
})
|
||||
.flex_grow()
|
||||
.with_sizing_behavior(sizing_behavior)
|
||||
.track_scroll(vertical_scroll_handle)
|
||||
.into_any_element(),
|
||||
)
|
||||
})
|
||||
.children(self.render_scrollbar(cx)),
|
||||
)
|
||||
}
|
||||
})
|
||||
// Click out to cancel right clicked row
|
||||
.when(self.right_clicked_index.is_some(), |this| {
|
||||
this.on_mouse_down_out(cx.listener(|this, _, cx| {
|
||||
this.right_clicked_index = None;
|
||||
cx.notify();
|
||||
}))
|
||||
})
|
||||
}
|
||||
}
|
||||
169
crates/ui/src/list/list_item.rs
Normal file
169
crates/ui/src/list/list_item.rs
Normal file
@@ -0,0 +1,169 @@
|
||||
use crate::{h_flex, theme::ActiveTheme, Disableable, Icon, IconName, Selectable, Sizable as _};
|
||||
use gpui::{
|
||||
div, prelude::FluentBuilder as _, AnyElement, ClickEvent, Div, ElementId, InteractiveElement,
|
||||
IntoElement, MouseButton, MouseMoveEvent, ParentElement, RenderOnce, Stateful,
|
||||
StatefulInteractiveElement as _, Styled, WindowContext,
|
||||
};
|
||||
use smallvec::SmallVec;
|
||||
|
||||
#[derive(IntoElement)]
|
||||
pub struct ListItem {
|
||||
id: ElementId,
|
||||
base: Stateful<Div>,
|
||||
disabled: bool,
|
||||
selected: bool,
|
||||
confirmed: bool,
|
||||
check_icon: Option<Icon>,
|
||||
on_click: Option<Box<dyn Fn(&ClickEvent, &mut WindowContext) + 'static>>,
|
||||
on_mouse_enter: Option<Box<dyn Fn(&MouseMoveEvent, &mut WindowContext) + 'static>>,
|
||||
suffix: Option<Box<dyn Fn(&mut WindowContext) -> AnyElement + 'static>>,
|
||||
children: SmallVec<[AnyElement; 2]>,
|
||||
}
|
||||
|
||||
impl ListItem {
|
||||
pub fn new(id: impl Into<ElementId>) -> Self {
|
||||
let id: ElementId = id.into();
|
||||
Self {
|
||||
id: id.clone(),
|
||||
base: h_flex().id(id).gap_x_1().py_1().px_2().text_base(),
|
||||
disabled: false,
|
||||
selected: false,
|
||||
confirmed: false,
|
||||
on_click: None,
|
||||
on_mouse_enter: None,
|
||||
check_icon: None,
|
||||
suffix: None,
|
||||
children: SmallVec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Set to show check icon, default is None.
|
||||
pub fn check_icon(mut self, icon: IconName) -> Self {
|
||||
self.check_icon = Some(Icon::new(icon));
|
||||
self
|
||||
}
|
||||
|
||||
/// Set ListItem as the selected item style.
|
||||
pub fn selected(mut self, selected: bool) -> Self {
|
||||
self.selected = selected;
|
||||
self
|
||||
}
|
||||
|
||||
/// Set ListItem as the confirmed item style, it will show a check icon.
|
||||
pub fn confirmed(mut self, confirmed: bool) -> Self {
|
||||
self.confirmed = confirmed;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn disabled(mut self, disabled: bool) -> Self {
|
||||
self.disabled = disabled;
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the suffix element of the input field, for example a clear button.
|
||||
pub fn suffix<F, E>(mut self, builder: F) -> Self
|
||||
where
|
||||
F: Fn(&mut WindowContext) -> E + 'static,
|
||||
E: IntoElement,
|
||||
{
|
||||
self.suffix = Some(Box::new(move |cx| builder(cx).into_any_element()));
|
||||
self
|
||||
}
|
||||
|
||||
pub fn on_click(mut self, handler: impl Fn(&ClickEvent, &mut WindowContext) + 'static) -> Self {
|
||||
self.on_click = Some(Box::new(handler));
|
||||
self
|
||||
}
|
||||
|
||||
pub fn on_mouse_enter(
|
||||
mut self,
|
||||
handler: impl Fn(&MouseMoveEvent, &mut WindowContext) + 'static,
|
||||
) -> Self {
|
||||
self.on_mouse_enter = Some(Box::new(handler));
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl Disableable for ListItem {
|
||||
fn disabled(mut self, disabled: bool) -> Self {
|
||||
self.disabled = disabled;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl Selectable for ListItem {
|
||||
fn element_id(&self) -> &ElementId {
|
||||
&self.id
|
||||
}
|
||||
|
||||
fn selected(mut self, selected: bool) -> Self {
|
||||
self.selected = selected;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl Styled for ListItem {
|
||||
fn style(&mut self) -> &mut gpui::StyleRefinement {
|
||||
self.base.style()
|
||||
}
|
||||
}
|
||||
|
||||
impl ParentElement for ListItem {
|
||||
fn extend(&mut self, elements: impl IntoIterator<Item = gpui::AnyElement>) {
|
||||
self.children.extend(elements);
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for ListItem {
|
||||
fn render(self, cx: &mut WindowContext) -> impl IntoElement {
|
||||
let is_active = self.selected || self.confirmed;
|
||||
|
||||
self.base
|
||||
.text_color(cx.theme().foreground)
|
||||
.relative()
|
||||
.items_center()
|
||||
.justify_between()
|
||||
.when_some(self.on_click, |this, on_click| {
|
||||
if !self.disabled {
|
||||
this.cursor_pointer()
|
||||
.on_mouse_down(MouseButton::Left, move |_, cx| {
|
||||
cx.stop_propagation();
|
||||
})
|
||||
.on_click(on_click)
|
||||
} else {
|
||||
this
|
||||
}
|
||||
})
|
||||
.when(is_active, |this| this.bg(cx.theme().list_active))
|
||||
.when(!is_active && !self.disabled, |this| {
|
||||
this.hover(|this| this.bg(cx.theme().list_hover))
|
||||
})
|
||||
// Mouse enter
|
||||
.when_some(self.on_mouse_enter, |this, on_mouse_enter| {
|
||||
if !self.disabled {
|
||||
this.on_mouse_move(move |ev, cx| (on_mouse_enter)(ev, cx))
|
||||
} else {
|
||||
this
|
||||
}
|
||||
})
|
||||
.child(
|
||||
h_flex()
|
||||
.w_full()
|
||||
.items_center()
|
||||
.justify_between()
|
||||
.gap_x_1()
|
||||
.child(div().w_full().children(self.children))
|
||||
.when_some(self.check_icon, |this, icon| {
|
||||
this.child(
|
||||
div().w_5().items_center().justify_center().when(
|
||||
self.confirmed,
|
||||
|this| {
|
||||
this.child(icon.small().text_color(cx.theme().muted_foreground))
|
||||
},
|
||||
),
|
||||
)
|
||||
}),
|
||||
)
|
||||
.when_some(self.suffix, |this, suffix| this.child(suffix(cx)))
|
||||
}
|
||||
}
|
||||
5
crates/ui/src/list/mod.rs
Normal file
5
crates/ui/src/list/mod.rs
Normal file
@@ -0,0 +1,5 @@
|
||||
mod list;
|
||||
mod list_item;
|
||||
|
||||
pub use list::*;
|
||||
pub use list_item::*;
|
||||
248
crates/ui/src/modal.rs
Normal file
248
crates/ui/src/modal.rs
Normal file
@@ -0,0 +1,248 @@
|
||||
use std::{rc::Rc, time::Duration};
|
||||
|
||||
use gpui::{
|
||||
actions, anchored, div, hsla, prelude::FluentBuilder, px, relative, Animation,
|
||||
AnimationExt as _, AnyElement, AppContext, Bounds, ClickEvent, Div, FocusHandle, Hsla,
|
||||
InteractiveElement, IntoElement, KeyBinding, MouseButton, ParentElement, Pixels, Point,
|
||||
RenderOnce, SharedString, Styled, WindowContext,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
animation::cubic_bezier,
|
||||
button::{Button, ButtonVariants as _},
|
||||
theme::ActiveTheme as _,
|
||||
v_flex, ContextModal, IconName, Sizable as _,
|
||||
};
|
||||
|
||||
actions!(modal, [Escape]);
|
||||
|
||||
const CONTEXT: &str = "Modal";
|
||||
pub fn init(cx: &mut AppContext) {
|
||||
cx.bind_keys([KeyBinding::new("escape", Escape, Some(CONTEXT))])
|
||||
}
|
||||
|
||||
#[derive(IntoElement)]
|
||||
pub struct Modal {
|
||||
base: Div,
|
||||
title: Option<AnyElement>,
|
||||
footer: Option<AnyElement>,
|
||||
content: Div,
|
||||
width: Pixels,
|
||||
max_width: Option<Pixels>,
|
||||
margin_top: Option<Pixels>,
|
||||
|
||||
on_close: Rc<dyn Fn(&ClickEvent, &mut WindowContext) + 'static>,
|
||||
show_close: bool,
|
||||
overlay: bool,
|
||||
keyboard: bool,
|
||||
|
||||
/// This will be change when open the modal, the focus handle is create when open the modal.
|
||||
pub(crate) focus_handle: FocusHandle,
|
||||
pub(crate) layer_ix: usize,
|
||||
pub(crate) overlay_visible: bool,
|
||||
}
|
||||
|
||||
pub(crate) fn overlay_color(overlay: bool, cx: &WindowContext) -> Hsla {
|
||||
if !overlay {
|
||||
return hsla(0., 0., 0., 0.);
|
||||
}
|
||||
|
||||
if cx.theme().mode.is_dark() {
|
||||
hsla(0., 1., 1., 0.06)
|
||||
} else {
|
||||
hsla(0., 0., 0., 0.06)
|
||||
}
|
||||
}
|
||||
|
||||
impl Modal {
|
||||
pub fn new(cx: &mut WindowContext) -> Self {
|
||||
let base = v_flex()
|
||||
.bg(cx.theme().background)
|
||||
.border_1()
|
||||
.border_color(cx.theme().border)
|
||||
.rounded_lg()
|
||||
.shadow_xl()
|
||||
.min_h_48()
|
||||
.p_4()
|
||||
.gap_4();
|
||||
|
||||
Self {
|
||||
base,
|
||||
focus_handle: cx.focus_handle(),
|
||||
title: None,
|
||||
footer: None,
|
||||
content: v_flex(),
|
||||
margin_top: None,
|
||||
width: px(480.),
|
||||
max_width: None,
|
||||
overlay: true,
|
||||
keyboard: true,
|
||||
layer_ix: 0,
|
||||
overlay_visible: true,
|
||||
on_close: Rc::new(|_, _| {}),
|
||||
show_close: true,
|
||||
}
|
||||
}
|
||||
|
||||
/// Sets the title of the modal.
|
||||
pub fn title(mut self, title: impl IntoElement) -> Self {
|
||||
self.title = Some(title.into_any_element());
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the footer of the modal.
|
||||
pub fn footer(mut self, footer: impl IntoElement) -> Self {
|
||||
self.footer = Some(footer.into_any_element());
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets the callback for when the modal is closed.
|
||||
pub fn on_close(
|
||||
mut self,
|
||||
on_close: impl Fn(&ClickEvent, &mut WindowContext) + 'static,
|
||||
) -> Self {
|
||||
self.on_close = Rc::new(on_close);
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets the false to hide close icon, default: true
|
||||
pub fn show_close(mut self, show_close: bool) -> Self {
|
||||
self.show_close = show_close;
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the top offset of the modal, defaults to None, will use the 1/10 of the viewport height.
|
||||
pub fn margin_top(mut self, margin_top: Pixels) -> Self {
|
||||
self.margin_top = Some(margin_top);
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets the width of the modal, defaults to 480px.
|
||||
pub fn width(mut self, width: Pixels) -> Self {
|
||||
self.width = width;
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the maximum width of the modal, defaults to `None`.
|
||||
pub fn max_w(mut self, max_width: Pixels) -> Self {
|
||||
self.max_width = Some(max_width);
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the overlay of the modal, defaults to `true`.
|
||||
pub fn overlay(mut self, overlay: bool) -> Self {
|
||||
self.overlay = overlay;
|
||||
self
|
||||
}
|
||||
|
||||
/// Set whether to support keyboard esc to close the modal, defaults to `true`.
|
||||
pub fn keyboard(mut self, keyboard: bool) -> Self {
|
||||
self.keyboard = keyboard;
|
||||
self
|
||||
}
|
||||
|
||||
pub(crate) fn has_overlay(&self) -> bool {
|
||||
self.overlay
|
||||
}
|
||||
}
|
||||
|
||||
impl ParentElement for Modal {
|
||||
fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
|
||||
self.content.extend(elements);
|
||||
}
|
||||
}
|
||||
|
||||
impl Styled for Modal {
|
||||
fn style(&mut self) -> &mut gpui::StyleRefinement {
|
||||
self.base.style()
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for Modal {
|
||||
fn render(self, cx: &mut WindowContext) -> impl gpui::IntoElement {
|
||||
let layer_ix = self.layer_ix;
|
||||
let on_close = self.on_close.clone();
|
||||
let view_size = cx.viewport_size();
|
||||
let bounds = Bounds {
|
||||
origin: Point::default(),
|
||||
size: view_size,
|
||||
};
|
||||
let offset_top = px(layer_ix as f32 * 16.);
|
||||
let y = self.margin_top.unwrap_or(view_size.height / 10.) + offset_top;
|
||||
let x = bounds.center().x - self.width / 2.;
|
||||
|
||||
anchored().snap_to_window().child(
|
||||
div()
|
||||
.occlude()
|
||||
.w(view_size.width)
|
||||
.h(view_size.height)
|
||||
.when(self.overlay_visible, |this| {
|
||||
this.bg(overlay_color(self.overlay, cx))
|
||||
})
|
||||
.when(self.overlay, |this| {
|
||||
this.on_mouse_down(MouseButton::Left, {
|
||||
let on_close = self.on_close.clone();
|
||||
move |_, cx| {
|
||||
on_close(&ClickEvent::default(), cx);
|
||||
cx.close_modal();
|
||||
}
|
||||
})
|
||||
})
|
||||
.child(
|
||||
self.base
|
||||
.id(SharedString::from(format!("modal-{layer_ix}")))
|
||||
.key_context(CONTEXT)
|
||||
.track_focus(&self.focus_handle)
|
||||
.when(self.keyboard, |this| {
|
||||
this.on_action({
|
||||
let on_close = self.on_close.clone();
|
||||
move |_: &Escape, cx| {
|
||||
// FIXME:
|
||||
//
|
||||
// Here some Modal have no focus_handle, so it will not work will Escape key.
|
||||
// But by now, we `cx.close_modal()` going to close the last active model, so the Escape is unexpected to work.
|
||||
on_close(&ClickEvent::default(), cx);
|
||||
cx.close_modal();
|
||||
}
|
||||
})
|
||||
})
|
||||
.absolute()
|
||||
.occlude()
|
||||
.relative()
|
||||
.left(x)
|
||||
.top(y)
|
||||
.w(self.width)
|
||||
.when_some(self.max_width, |this, w| this.max_w(w))
|
||||
.when_some(self.title, |this, title| {
|
||||
this.child(div().line_height(relative(1.)).child(title))
|
||||
})
|
||||
.when(self.show_close, |this| {
|
||||
this.child(
|
||||
Button::new(SharedString::from(format!("modal-close-{layer_ix}")))
|
||||
.absolute()
|
||||
.top_2()
|
||||
.right_2()
|
||||
.small()
|
||||
.ghost()
|
||||
.icon(IconName::Close)
|
||||
.on_click(move |_, cx| {
|
||||
on_close(&ClickEvent::default(), cx);
|
||||
cx.close_modal();
|
||||
}),
|
||||
)
|
||||
})
|
||||
.child(self.content)
|
||||
.children(self.footer)
|
||||
.with_animation(
|
||||
"slide-down",
|
||||
Animation::new(Duration::from_secs_f64(0.25))
|
||||
.with_easing(cubic_bezier(0.32, 0.72, 0., 1.)),
|
||||
move |this, delta| {
|
||||
let y_offset = px(0.) + delta * px(30.);
|
||||
this.top(y + y_offset).opacity(delta)
|
||||
},
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
369
crates/ui/src/notification.rs
Normal file
369
crates/ui/src/notification.rs
Normal file
@@ -0,0 +1,369 @@
|
||||
use std::{any::TypeId, collections::VecDeque, sync::Arc, time::Duration};
|
||||
|
||||
use gpui::{
|
||||
div, prelude::FluentBuilder, px, Animation, AnimationExt, ClickEvent, DismissEvent, ElementId,
|
||||
EventEmitter, InteractiveElement as _, IntoElement, ParentElement as _, Render, SharedString,
|
||||
StatefulInteractiveElement, Styled, View, ViewContext, VisualContext, WindowContext,
|
||||
};
|
||||
use smol::Timer;
|
||||
|
||||
use crate::{
|
||||
animation::cubic_bezier,
|
||||
button::{Button, ButtonVariants as _},
|
||||
h_flex,
|
||||
theme::ActiveTheme as _,
|
||||
v_flex, Icon, IconName, Sizable as _, StyledExt,
|
||||
};
|
||||
|
||||
pub enum NotificationType {
|
||||
Info,
|
||||
Success,
|
||||
Warning,
|
||||
Error,
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Clone)]
|
||||
pub(crate) enum NotificationId {
|
||||
Id(TypeId),
|
||||
IdAndElementId(TypeId, ElementId),
|
||||
}
|
||||
|
||||
impl From<TypeId> for NotificationId {
|
||||
fn from(type_id: TypeId) -> Self {
|
||||
Self::Id(type_id)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<(TypeId, ElementId)> for NotificationId {
|
||||
fn from((type_id, id): (TypeId, ElementId)) -> Self {
|
||||
Self::IdAndElementId(type_id, id)
|
||||
}
|
||||
}
|
||||
|
||||
/// A notification element.
|
||||
pub struct Notification {
|
||||
/// The id is used make the notification unique.
|
||||
/// Then you push a notification with the same id, the previous notification will be replaced.
|
||||
///
|
||||
/// None means the notification will be added to the end of the list.
|
||||
id: NotificationId,
|
||||
type_: NotificationType,
|
||||
title: Option<SharedString>,
|
||||
message: SharedString,
|
||||
icon: Option<Icon>,
|
||||
autohide: bool,
|
||||
on_click: Option<Arc<dyn Fn(&ClickEvent, &mut WindowContext)>>,
|
||||
closing: bool,
|
||||
}
|
||||
|
||||
impl From<String> for Notification {
|
||||
fn from(s: String) -> Self {
|
||||
Self::new(s)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<SharedString> for Notification {
|
||||
fn from(s: SharedString) -> Self {
|
||||
Self::new(s)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&'static str> for Notification {
|
||||
fn from(s: &'static str) -> Self {
|
||||
Self::new(s)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<(NotificationType, &'static str)> for Notification {
|
||||
fn from((type_, content): (NotificationType, &'static str)) -> Self {
|
||||
Self::new(content).with_type(type_)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<(NotificationType, SharedString)> for Notification {
|
||||
fn from((type_, content): (NotificationType, SharedString)) -> Self {
|
||||
Self::new(content).with_type(type_)
|
||||
}
|
||||
}
|
||||
|
||||
struct DefaultIdType;
|
||||
impl Notification {
|
||||
/// Create a new notification with the given content.
|
||||
///
|
||||
/// default width is 320px.
|
||||
pub fn new(message: impl Into<SharedString>) -> Self {
|
||||
let id: SharedString = uuid::Uuid::new_v4().to_string().into();
|
||||
let id = (TypeId::of::<DefaultIdType>(), id.into());
|
||||
|
||||
Self {
|
||||
id: id.into(),
|
||||
title: None,
|
||||
message: message.into(),
|
||||
type_: NotificationType::Info,
|
||||
icon: None,
|
||||
autohide: true,
|
||||
on_click: None,
|
||||
closing: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn info(message: impl Into<SharedString>) -> Self {
|
||||
Self::new(message).with_type(NotificationType::Info)
|
||||
}
|
||||
|
||||
pub fn success(message: impl Into<SharedString>) -> Self {
|
||||
Self::new(message).with_type(NotificationType::Success)
|
||||
}
|
||||
|
||||
pub fn warning(message: impl Into<SharedString>) -> Self {
|
||||
Self::new(message).with_type(NotificationType::Warning)
|
||||
}
|
||||
|
||||
pub fn error(message: impl Into<SharedString>) -> Self {
|
||||
Self::new(message).with_type(NotificationType::Error)
|
||||
}
|
||||
|
||||
/// Set the type for unique identification of the notification.
|
||||
///
|
||||
/// ```rs
|
||||
/// struct MyNotificationKind;
|
||||
/// let notification = Notification::new("Hello").id::<MyNotificationKind>();
|
||||
/// ```
|
||||
pub fn id<T: Sized + 'static>(mut self) -> Self {
|
||||
self.id = TypeId::of::<T>().into();
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the type and id of the notification, used to uniquely identify the notification.
|
||||
pub fn id1<T: Sized + 'static>(mut self, key: impl Into<ElementId>) -> Self {
|
||||
self.id = (TypeId::of::<T>(), key.into()).into();
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the title of the notification, default is None.
|
||||
///
|
||||
/// If title is None, the notification will not have a title.
|
||||
pub fn title(mut self, title: impl Into<SharedString>) -> Self {
|
||||
self.title = Some(title.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the icon of the notification.
|
||||
///
|
||||
/// If icon is None, the notification will use the default icon of the type.
|
||||
pub fn icon(mut self, icon: impl Into<Icon>) -> Self {
|
||||
self.icon = Some(icon.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the type of the notification, default is NotificationType::Info.
|
||||
pub fn with_type(mut self, type_: NotificationType) -> Self {
|
||||
self.type_ = type_;
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the auto hide of the notification, default is true.
|
||||
pub fn autohide(mut self, autohide: bool) -> Self {
|
||||
self.autohide = autohide;
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the click callback of the notification.
|
||||
pub fn on_click(
|
||||
mut self,
|
||||
on_click: impl Fn(&ClickEvent, &mut WindowContext) + 'static,
|
||||
) -> Self {
|
||||
self.on_click = Some(Arc::new(on_click));
|
||||
self
|
||||
}
|
||||
|
||||
fn dismiss(&mut self, _: &ClickEvent, cx: &mut ViewContext<Self>) {
|
||||
self.closing = true;
|
||||
cx.notify();
|
||||
|
||||
// Dismiss the notification after 0.15s to show the animation.
|
||||
cx.spawn(|view, mut cx| async move {
|
||||
Timer::after(Duration::from_secs_f32(0.15)).await;
|
||||
cx.update(|cx| {
|
||||
if let Some(view) = view.upgrade() {
|
||||
view.update(cx, |view, cx| {
|
||||
view.closing = false;
|
||||
cx.emit(DismissEvent);
|
||||
});
|
||||
}
|
||||
})
|
||||
})
|
||||
.detach()
|
||||
}
|
||||
}
|
||||
impl EventEmitter<DismissEvent> for Notification {}
|
||||
impl FluentBuilder for Notification {}
|
||||
impl Render for Notification {
|
||||
fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
|
||||
let closing = self.closing;
|
||||
let icon = match self.icon.clone() {
|
||||
Some(icon) => icon,
|
||||
None => match self.type_ {
|
||||
NotificationType::Info => Icon::new(IconName::Info).text_color(crate::blue_500()),
|
||||
NotificationType::Success => {
|
||||
Icon::new(IconName::CircleCheck).text_color(crate::green_500())
|
||||
}
|
||||
NotificationType::Warning => {
|
||||
Icon::new(IconName::TriangleAlert).text_color(crate::yellow_500())
|
||||
}
|
||||
NotificationType::Error => {
|
||||
Icon::new(IconName::CircleX).text_color(crate::red_500())
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
div()
|
||||
.id("notification")
|
||||
.group("")
|
||||
.occlude()
|
||||
.relative()
|
||||
.w_96()
|
||||
.border_1()
|
||||
.border_color(cx.theme().border)
|
||||
.bg(cx.theme().popover)
|
||||
.rounded_md()
|
||||
.shadow_md()
|
||||
.py_2()
|
||||
.px_4()
|
||||
.gap_3()
|
||||
.child(div().absolute().top_3().left_4().child(icon))
|
||||
.child(
|
||||
v_flex()
|
||||
.pl_6()
|
||||
.gap_1()
|
||||
.when_some(self.title.clone(), |this, title| {
|
||||
this.child(div().text_sm().font_semibold().child(title))
|
||||
})
|
||||
.overflow_hidden()
|
||||
.child(div().text_sm().child(self.message.clone())),
|
||||
)
|
||||
.when_some(self.on_click.clone(), |this, on_click| {
|
||||
this.cursor_pointer()
|
||||
.on_click(cx.listener(move |view, event, cx| {
|
||||
view.dismiss(event, cx);
|
||||
on_click(event, cx);
|
||||
}))
|
||||
})
|
||||
.when(!self.autohide, |this| {
|
||||
this.child(
|
||||
h_flex()
|
||||
.absolute()
|
||||
.top_1()
|
||||
.right_1()
|
||||
.invisible()
|
||||
.group_hover("", |this| this.visible())
|
||||
.child(
|
||||
Button::new("close")
|
||||
.icon(IconName::Close)
|
||||
.ghost()
|
||||
.xsmall()
|
||||
.on_click(cx.listener(Self::dismiss)),
|
||||
),
|
||||
)
|
||||
})
|
||||
.with_animation(
|
||||
ElementId::NamedInteger("slide-down".into(), closing as usize),
|
||||
Animation::new(Duration::from_secs_f64(0.15))
|
||||
.with_easing(cubic_bezier(0.4, 0., 0.2, 1.)),
|
||||
move |this, delta| {
|
||||
if closing {
|
||||
let x_offset = px(0.) + delta * px(45.);
|
||||
this.left(px(0.) + x_offset).opacity(1. - delta)
|
||||
} else {
|
||||
let y_offset = px(-45.) + delta * px(45.);
|
||||
this.top(px(0.) + y_offset).opacity(delta)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// A list of notifications.
|
||||
pub struct NotificationList {
|
||||
/// Notifications that will be auto hidden.
|
||||
pub(crate) notifications: VecDeque<View<Notification>>,
|
||||
expanded: bool,
|
||||
}
|
||||
|
||||
impl NotificationList {
|
||||
pub fn new(_cx: &mut ViewContext<Self>) -> Self {
|
||||
Self {
|
||||
notifications: VecDeque::new(),
|
||||
expanded: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn push(&mut self, notification: impl Into<Notification>, cx: &mut ViewContext<Self>) {
|
||||
let notification = notification.into();
|
||||
let id = notification.id.clone();
|
||||
let autohide = notification.autohide;
|
||||
|
||||
// Remove the notification by id, for keep unique.
|
||||
self.notifications.retain(|note| note.read(cx).id != id);
|
||||
|
||||
let notification = cx.new_view(|_| notification);
|
||||
cx.subscribe(¬ification, move |view, _, _: &DismissEvent, cx| {
|
||||
view.notifications.retain(|note| id != note.read(cx).id);
|
||||
})
|
||||
.detach();
|
||||
|
||||
self.notifications.push_back(notification.clone());
|
||||
if autohide {
|
||||
// Sleep for 5 seconds to autohide the notification
|
||||
cx.spawn(|_, mut cx| async move {
|
||||
Timer::after(Duration::from_secs(5)).await;
|
||||
|
||||
if let Err(err) = notification
|
||||
.update(&mut cx, |note, cx| note.dismiss(&ClickEvent::default(), cx))
|
||||
{
|
||||
println!("failed to auto hide notification: {:?}", err);
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub fn clear(&mut self, cx: &mut ViewContext<Self>) {
|
||||
self.notifications.clear();
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub fn notifications(&self) -> Vec<View<Notification>> {
|
||||
self.notifications.iter().cloned().collect()
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for NotificationList {
|
||||
fn render(&mut self, cx: &mut gpui::ViewContext<Self>) -> impl IntoElement {
|
||||
let size = cx.viewport_size();
|
||||
let items = self.notifications.iter().rev().take(10).rev().cloned();
|
||||
|
||||
div()
|
||||
.absolute()
|
||||
.flex()
|
||||
.top_4()
|
||||
.bottom_4()
|
||||
.right_4()
|
||||
.justify_end()
|
||||
.child(
|
||||
v_flex()
|
||||
.id("notification-list")
|
||||
.absolute()
|
||||
.relative()
|
||||
.right_0()
|
||||
.h(size.height - px(8.))
|
||||
.on_hover(cx.listener(|view, hovered, cx| {
|
||||
view.expanded = *hovered;
|
||||
cx.notify()
|
||||
}))
|
||||
.gap_3()
|
||||
.children(items),
|
||||
)
|
||||
}
|
||||
}
|
||||
168
crates/ui/src/number_input.rs
Normal file
168
crates/ui/src/number_input.rs
Normal file
@@ -0,0 +1,168 @@
|
||||
use gpui::{
|
||||
actions, AppContext, EventEmitter, FocusHandle, FocusableView, InteractiveElement, IntoElement,
|
||||
KeyBinding, ParentElement, Render, SharedString, Styled, Subscription, View, ViewContext,
|
||||
VisualContext,
|
||||
};
|
||||
use regex::Regex;
|
||||
|
||||
use crate::{
|
||||
button::{Button, ButtonVariants as _},
|
||||
h_flex,
|
||||
input::{InputEvent, TextInput},
|
||||
prelude::FluentBuilder,
|
||||
theme::ActiveTheme,
|
||||
IconName, Sizable, Size, StyledExt,
|
||||
};
|
||||
|
||||
actions!(number_input, [Increment, Decrement]);
|
||||
|
||||
const KEY_CONTENT: &str = "NumberInput";
|
||||
|
||||
pub fn init(cx: &mut AppContext) {
|
||||
cx.bind_keys(vec![
|
||||
KeyBinding::new("up", Increment, Some(KEY_CONTENT)),
|
||||
KeyBinding::new("down", Decrement, Some(KEY_CONTENT)),
|
||||
]);
|
||||
}
|
||||
|
||||
pub struct NumberInput {
|
||||
input: View<TextInput>,
|
||||
_subscriptions: Vec<Subscription>,
|
||||
}
|
||||
|
||||
impl NumberInput {
|
||||
pub fn new(cx: &mut ViewContext<Self>) -> Self {
|
||||
// Default pattern for the number input.
|
||||
let pattern = Regex::new(r"^-?(\d+)?\.?(\d+)?$").unwrap();
|
||||
|
||||
let input = cx.new_view(|cx| TextInput::new(cx).pattern(pattern).appearance(false));
|
||||
|
||||
let _subscriptions = vec![cx.subscribe(&input, |_, _, event: &InputEvent, cx| {
|
||||
cx.emit(NumberInputEvent::Input(event.clone()));
|
||||
})];
|
||||
|
||||
Self {
|
||||
input,
|
||||
_subscriptions,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn placeholder(
|
||||
self,
|
||||
placeholder: impl Into<SharedString>,
|
||||
cx: &mut ViewContext<Self>,
|
||||
) -> Self {
|
||||
self.input
|
||||
.update(cx, |input, _| input.set_placeholder(placeholder));
|
||||
self
|
||||
}
|
||||
|
||||
pub fn set_placeholder(&self, text: impl Into<SharedString>, cx: &mut ViewContext<Self>) {
|
||||
self.input.update(cx, |input, _| {
|
||||
input.set_placeholder(text);
|
||||
});
|
||||
}
|
||||
|
||||
pub fn pattern(self, pattern: regex::Regex, cx: &mut ViewContext<Self>) -> Self {
|
||||
self.input.update(cx, |input, _| input.set_pattern(pattern));
|
||||
self
|
||||
}
|
||||
|
||||
pub fn set_size(self, size: Size, cx: &mut ViewContext<Self>) -> Self {
|
||||
self.input.update(cx, |input, cx| input.set_size(size, cx));
|
||||
self
|
||||
}
|
||||
|
||||
pub fn small(self, cx: &mut ViewContext<Self>) -> Self {
|
||||
self.set_size(Size::Small, cx)
|
||||
}
|
||||
|
||||
pub fn xsmall(self, cx: &mut ViewContext<Self>) -> Self {
|
||||
self.set_size(Size::XSmall, cx)
|
||||
}
|
||||
|
||||
pub fn large(self, cx: &mut ViewContext<Self>) -> Self {
|
||||
self.set_size(Size::Large, cx)
|
||||
}
|
||||
|
||||
pub fn set_value(&self, text: impl Into<SharedString>, cx: &mut ViewContext<Self>) {
|
||||
self.input.update(cx, |input, cx| input.set_text(text, cx))
|
||||
}
|
||||
|
||||
pub fn set_disabled(&self, disabled: bool, cx: &mut ViewContext<Self>) {
|
||||
self.input
|
||||
.update(cx, |input, cx| input.set_disabled(disabled, cx));
|
||||
}
|
||||
|
||||
pub fn increment(&mut self, cx: &mut ViewContext<Self>) {
|
||||
self.handle_increment(&Increment, cx);
|
||||
}
|
||||
|
||||
pub fn decrement(&mut self, cx: &mut ViewContext<Self>) {
|
||||
self.handle_decrement(&Decrement, cx);
|
||||
}
|
||||
|
||||
fn handle_increment(&mut self, _: &Increment, cx: &mut ViewContext<Self>) {
|
||||
self.on_step(StepAction::Increment, cx);
|
||||
}
|
||||
|
||||
fn handle_decrement(&mut self, _: &Decrement, cx: &mut ViewContext<Self>) {
|
||||
self.on_step(StepAction::Decrement, cx);
|
||||
}
|
||||
|
||||
fn on_step(&mut self, action: StepAction, cx: &mut ViewContext<Self>) {
|
||||
cx.emit(NumberInputEvent::Step(action));
|
||||
}
|
||||
}
|
||||
|
||||
impl FocusableView for NumberInput {
|
||||
fn focus_handle(&self, cx: &AppContext) -> FocusHandle {
|
||||
self.input.focus_handle(cx)
|
||||
}
|
||||
}
|
||||
|
||||
pub enum StepAction {
|
||||
Decrement,
|
||||
Increment,
|
||||
}
|
||||
|
||||
pub enum NumberInputEvent {
|
||||
Input(InputEvent),
|
||||
Step(StepAction),
|
||||
}
|
||||
|
||||
impl EventEmitter<NumberInputEvent> for NumberInput {}
|
||||
|
||||
impl Render for NumberInput {
|
||||
fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
|
||||
let focused = self.input.focus_handle(cx).is_focused(cx);
|
||||
|
||||
h_flex()
|
||||
.key_context(KEY_CONTENT)
|
||||
.on_action(cx.listener(Self::handle_increment))
|
||||
.on_action(cx.listener(Self::handle_decrement))
|
||||
.flex_1()
|
||||
.px_1()
|
||||
.gap_x_3()
|
||||
.bg(cx.theme().background)
|
||||
.border_color(cx.theme().border)
|
||||
.border_1()
|
||||
.rounded_md()
|
||||
.when(focused, |this| this.outline(cx))
|
||||
.child(
|
||||
Button::new("minus")
|
||||
.ghost()
|
||||
.xsmall()
|
||||
.icon(IconName::Minus)
|
||||
.on_click(cx.listener(|this, _, cx| this.on_step(StepAction::Decrement, cx))),
|
||||
)
|
||||
.child(self.input.clone())
|
||||
.child(
|
||||
Button::new("plus")
|
||||
.ghost()
|
||||
.xsmall()
|
||||
.icon(IconName::Plus)
|
||||
.on_click(cx.listener(|this, _, cx| this.on_step(StepAction::Increment, cx))),
|
||||
)
|
||||
}
|
||||
}
|
||||
408
crates/ui/src/popover.rs
Normal file
408
crates/ui/src/popover.rs
Normal file
@@ -0,0 +1,408 @@
|
||||
use gpui::{
|
||||
actions, anchored, deferred, div, prelude::FluentBuilder as _, px, AnchorCorner, AnyElement,
|
||||
AppContext, Bounds, DismissEvent, DispatchPhase, Element, ElementId, EventEmitter, FocusHandle,
|
||||
FocusableView, GlobalElementId, Hitbox, InteractiveElement as _, IntoElement, KeyBinding,
|
||||
LayoutId, ManagedView, MouseButton, MouseDownEvent, ParentElement, Pixels, Point, Render,
|
||||
Style, StyleRefinement, Styled, View, ViewContext, VisualContext, WindowContext,
|
||||
};
|
||||
use std::{cell::RefCell, rc::Rc};
|
||||
|
||||
use crate::{Selectable, StyledExt as _};
|
||||
|
||||
const CONTEXT: &str = "Popover";
|
||||
|
||||
actions!(popover, [Escape]);
|
||||
|
||||
pub fn init(cx: &mut AppContext) {
|
||||
cx.bind_keys([KeyBinding::new("escape", Escape, Some(CONTEXT))])
|
||||
}
|
||||
|
||||
pub struct PopoverContent {
|
||||
focus_handle: FocusHandle,
|
||||
content: Rc<dyn Fn(&mut ViewContext<Self>) -> AnyElement>,
|
||||
max_width: Option<Pixels>,
|
||||
}
|
||||
|
||||
impl PopoverContent {
|
||||
pub fn new<B>(cx: &mut WindowContext, content: B) -> Self
|
||||
where
|
||||
B: Fn(&mut ViewContext<Self>) -> AnyElement + 'static,
|
||||
{
|
||||
let focus_handle = cx.focus_handle();
|
||||
|
||||
Self {
|
||||
focus_handle,
|
||||
content: Rc::new(content),
|
||||
max_width: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn max_w(mut self, max_width: Pixels) -> Self {
|
||||
self.max_width = Some(max_width);
|
||||
self
|
||||
}
|
||||
}
|
||||
impl EventEmitter<DismissEvent> for PopoverContent {}
|
||||
|
||||
impl FocusableView for PopoverContent {
|
||||
fn focus_handle(&self, _cx: &AppContext) -> FocusHandle {
|
||||
self.focus_handle.clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for PopoverContent {
|
||||
fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
|
||||
div()
|
||||
.track_focus(&self.focus_handle)
|
||||
.key_context(CONTEXT)
|
||||
.on_action(cx.listener(|_, _: &Escape, cx| cx.emit(DismissEvent)))
|
||||
.p_2()
|
||||
.when_some(self.max_width, |this, v| this.max_w(v))
|
||||
.child(self.content.clone()(cx))
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Popover<M: ManagedView> {
|
||||
id: ElementId,
|
||||
anchor: AnchorCorner,
|
||||
trigger: Option<Box<dyn FnOnce(bool, &WindowContext) -> AnyElement + 'static>>,
|
||||
content: Option<Rc<dyn Fn(&mut WindowContext) -> View<M> + 'static>>,
|
||||
/// Style for trigger element.
|
||||
/// This is used for hotfix the trigger element style to support w_full.
|
||||
trigger_style: Option<StyleRefinement>,
|
||||
mouse_button: MouseButton,
|
||||
no_style: bool,
|
||||
}
|
||||
|
||||
impl<M> Popover<M>
|
||||
where
|
||||
M: ManagedView,
|
||||
{
|
||||
/// Create a new Popover with `view` mode.
|
||||
pub fn new(id: impl Into<ElementId>) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
anchor: AnchorCorner::TopLeft,
|
||||
trigger: None,
|
||||
trigger_style: None,
|
||||
content: None,
|
||||
mouse_button: MouseButton::Left,
|
||||
no_style: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn anchor(mut self, anchor: AnchorCorner) -> Self {
|
||||
self.anchor = anchor;
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the mouse button to trigger the popover, default is `MouseButton::Left`.
|
||||
pub fn mouse_button(mut self, mouse_button: MouseButton) -> Self {
|
||||
self.mouse_button = mouse_button;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn trigger<T>(mut self, trigger: T) -> Self
|
||||
where
|
||||
T: Selectable + IntoElement + 'static,
|
||||
{
|
||||
self.trigger = Some(Box::new(|is_open, _| {
|
||||
trigger.selected(is_open).into_any_element()
|
||||
}));
|
||||
self
|
||||
}
|
||||
|
||||
pub fn trigger_style(mut self, style: StyleRefinement) -> Self {
|
||||
self.trigger_style = Some(style);
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the content of the popover.
|
||||
///
|
||||
/// The `content` is a closure that returns an `AnyElement`.
|
||||
pub fn content<C>(mut self, content: C) -> Self
|
||||
where
|
||||
C: Fn(&mut WindowContext) -> View<M> + 'static,
|
||||
{
|
||||
self.content = Some(Rc::new(content));
|
||||
self
|
||||
}
|
||||
|
||||
/// Set whether the popover no style, default is `false`.
|
||||
///
|
||||
/// If no style:
|
||||
///
|
||||
/// - The popover will not have a bg, border, shadow, or padding.
|
||||
/// - The click out of the popover will not dismiss it.
|
||||
pub fn no_style(mut self) -> Self {
|
||||
self.no_style = true;
|
||||
self
|
||||
}
|
||||
|
||||
fn render_trigger(&mut self, is_open: bool, cx: &mut WindowContext) -> AnyElement {
|
||||
let Some(trigger) = self.trigger.take() else {
|
||||
return div().into_any_element();
|
||||
};
|
||||
|
||||
(trigger)(is_open, cx)
|
||||
}
|
||||
|
||||
fn resolved_corner(&self, bounds: Bounds<Pixels>) -> Point<Pixels> {
|
||||
match self.anchor {
|
||||
AnchorCorner::TopLeft => AnchorCorner::BottomLeft,
|
||||
AnchorCorner::TopRight => AnchorCorner::BottomRight,
|
||||
AnchorCorner::BottomLeft => AnchorCorner::TopLeft,
|
||||
AnchorCorner::BottomRight => AnchorCorner::TopRight,
|
||||
}
|
||||
.corner(bounds)
|
||||
}
|
||||
|
||||
fn with_element_state<R>(
|
||||
&mut self,
|
||||
id: &GlobalElementId,
|
||||
cx: &mut WindowContext,
|
||||
f: impl FnOnce(&mut Self, &mut PopoverElementState<M>, &mut WindowContext) -> R,
|
||||
) -> R {
|
||||
cx.with_optional_element_state::<PopoverElementState<M>, _>(
|
||||
Some(id),
|
||||
|element_state, cx| {
|
||||
let mut element_state = element_state.unwrap().unwrap_or_default();
|
||||
let result = f(self, &mut element_state, cx);
|
||||
(result, Some(element_state))
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl<M> IntoElement for Popover<M>
|
||||
where
|
||||
M: ManagedView,
|
||||
{
|
||||
type Element = Self;
|
||||
|
||||
fn into_element(self) -> Self::Element {
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
pub struct PopoverElementState<M> {
|
||||
trigger_layout_id: Option<LayoutId>,
|
||||
popover_layout_id: Option<LayoutId>,
|
||||
popover_element: Option<AnyElement>,
|
||||
trigger_element: Option<AnyElement>,
|
||||
content_view: Rc<RefCell<Option<View<M>>>>,
|
||||
/// Trigger bounds for positioning the popover.
|
||||
trigger_bounds: Option<Bounds<Pixels>>,
|
||||
}
|
||||
|
||||
impl<M> Default for PopoverElementState<M> {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
trigger_layout_id: None,
|
||||
popover_layout_id: None,
|
||||
popover_element: None,
|
||||
trigger_element: None,
|
||||
content_view: Rc::new(RefCell::new(None)),
|
||||
trigger_bounds: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct PrepaintState {
|
||||
hitbox: Hitbox,
|
||||
/// Trigger bounds for limit a rect to handle mouse click.
|
||||
trigger_bounds: Option<Bounds<Pixels>>,
|
||||
}
|
||||
|
||||
impl<M: ManagedView> Element for Popover<M> {
|
||||
type RequestLayoutState = PopoverElementState<M>;
|
||||
type PrepaintState = PrepaintState;
|
||||
|
||||
fn id(&self) -> Option<ElementId> {
|
||||
Some(self.id.clone())
|
||||
}
|
||||
|
||||
fn request_layout(
|
||||
&mut self,
|
||||
id: Option<&gpui::GlobalElementId>,
|
||||
cx: &mut WindowContext,
|
||||
) -> (gpui::LayoutId, Self::RequestLayoutState) {
|
||||
let mut style = Style::default();
|
||||
|
||||
// FIXME: Remove this and find a better way to handle this.
|
||||
// Apply trigger style, for support w_full for trigger.
|
||||
//
|
||||
// If remove this, the trigger will not support w_full.
|
||||
if let Some(trigger_style) = self.trigger_style.clone() {
|
||||
if let Some(width) = trigger_style.size.width {
|
||||
style.size.width = width;
|
||||
}
|
||||
if let Some(display) = trigger_style.display {
|
||||
style.display = display;
|
||||
}
|
||||
}
|
||||
|
||||
self.with_element_state(id.unwrap(), cx, |view, element_state, cx| {
|
||||
let mut popover_layout_id = None;
|
||||
let mut popover_element = None;
|
||||
let mut is_open = false;
|
||||
|
||||
if let Some(content_view) = element_state.content_view.borrow_mut().as_mut() {
|
||||
is_open = true;
|
||||
|
||||
let mut anchored = anchored()
|
||||
.snap_to_window_with_margin(px(8.))
|
||||
.anchor(view.anchor);
|
||||
if let Some(trigger_bounds) = element_state.trigger_bounds {
|
||||
anchored = anchored.position(view.resolved_corner(trigger_bounds));
|
||||
}
|
||||
|
||||
let mut element = {
|
||||
let content_view_mut = element_state.content_view.clone();
|
||||
let anchor = view.anchor;
|
||||
let no_style = view.no_style;
|
||||
deferred(
|
||||
anchored.child(
|
||||
div()
|
||||
.size_full()
|
||||
.occlude()
|
||||
.when(!no_style, |this| this.popover_style(cx))
|
||||
.map(|this| match anchor {
|
||||
AnchorCorner::TopLeft | AnchorCorner::TopRight => {
|
||||
this.top_1p5()
|
||||
}
|
||||
AnchorCorner::BottomLeft | AnchorCorner::BottomRight => {
|
||||
this.bottom_1p5()
|
||||
}
|
||||
})
|
||||
.child(content_view.clone())
|
||||
.when(!no_style, |this| {
|
||||
this.on_mouse_down_out(move |_, cx| {
|
||||
// Update the element_state.content_view to `None`,
|
||||
// so that the `paint`` method will not paint it.
|
||||
*content_view_mut.borrow_mut() = None;
|
||||
cx.refresh();
|
||||
})
|
||||
}),
|
||||
),
|
||||
)
|
||||
.with_priority(1)
|
||||
.into_any()
|
||||
};
|
||||
|
||||
popover_layout_id = Some(element.request_layout(cx));
|
||||
popover_element = Some(element);
|
||||
}
|
||||
|
||||
let mut trigger_element = view.render_trigger(is_open, cx);
|
||||
let trigger_layout_id = trigger_element.request_layout(cx);
|
||||
|
||||
let layout_id = cx.request_layout(
|
||||
style,
|
||||
Some(trigger_layout_id).into_iter().chain(popover_layout_id),
|
||||
);
|
||||
|
||||
(
|
||||
layout_id,
|
||||
PopoverElementState {
|
||||
trigger_layout_id: Some(trigger_layout_id),
|
||||
popover_layout_id,
|
||||
popover_element,
|
||||
trigger_element: Some(trigger_element),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn prepaint(
|
||||
&mut self,
|
||||
_id: Option<&gpui::GlobalElementId>,
|
||||
_bounds: gpui::Bounds<gpui::Pixels>,
|
||||
request_layout: &mut Self::RequestLayoutState,
|
||||
cx: &mut WindowContext,
|
||||
) -> Self::PrepaintState {
|
||||
if let Some(element) = &mut request_layout.trigger_element {
|
||||
element.prepaint(cx);
|
||||
}
|
||||
if let Some(element) = &mut request_layout.popover_element {
|
||||
element.prepaint(cx);
|
||||
}
|
||||
|
||||
let trigger_bounds = request_layout
|
||||
.trigger_layout_id
|
||||
.map(|id| cx.layout_bounds(id));
|
||||
|
||||
// Prepare the popover, for get the bounds of it for open window size.
|
||||
let _ = request_layout
|
||||
.popover_layout_id
|
||||
.map(|id| cx.layout_bounds(id));
|
||||
|
||||
let hitbox = cx.insert_hitbox(trigger_bounds.unwrap_or_default(), false);
|
||||
|
||||
PrepaintState {
|
||||
trigger_bounds,
|
||||
hitbox,
|
||||
}
|
||||
}
|
||||
|
||||
fn paint(
|
||||
&mut self,
|
||||
id: Option<&GlobalElementId>,
|
||||
_bounds: Bounds<Pixels>,
|
||||
request_layout: &mut Self::RequestLayoutState,
|
||||
prepaint: &mut Self::PrepaintState,
|
||||
cx: &mut WindowContext,
|
||||
) {
|
||||
self.with_element_state(id.unwrap(), cx, |this, element_state, cx| {
|
||||
element_state.trigger_bounds = prepaint.trigger_bounds;
|
||||
|
||||
if let Some(mut element) = request_layout.trigger_element.take() {
|
||||
element.paint(cx);
|
||||
}
|
||||
|
||||
if let Some(mut element) = request_layout.popover_element.take() {
|
||||
element.paint(cx);
|
||||
return;
|
||||
}
|
||||
|
||||
// When mouse click down in the trigger bounds, open the popover.
|
||||
let Some(content_build) = this.content.take() else {
|
||||
return;
|
||||
};
|
||||
let old_content_view = element_state.content_view.clone();
|
||||
let hitbox_id = prepaint.hitbox.id;
|
||||
let mouse_button = this.mouse_button;
|
||||
cx.on_mouse_event(move |event: &MouseDownEvent, phase, cx| {
|
||||
if phase == DispatchPhase::Bubble
|
||||
&& event.button == mouse_button
|
||||
&& hitbox_id.is_hovered(cx)
|
||||
{
|
||||
cx.stop_propagation();
|
||||
cx.prevent_default();
|
||||
|
||||
let new_content_view = (content_build)(cx);
|
||||
let old_content_view1 = old_content_view.clone();
|
||||
|
||||
let previous_focus_handle = cx.focused();
|
||||
|
||||
cx.subscribe(&new_content_view, move |modal, _: &DismissEvent, cx| {
|
||||
if modal.focus_handle(cx).contains_focused(cx) {
|
||||
if let Some(previous_focus_handle) = previous_focus_handle.as_ref() {
|
||||
cx.focus(previous_focus_handle);
|
||||
}
|
||||
}
|
||||
*old_content_view1.borrow_mut() = None;
|
||||
|
||||
cx.refresh();
|
||||
})
|
||||
.detach();
|
||||
|
||||
cx.focus_view(&new_content_view);
|
||||
*old_content_view.borrow_mut() = Some(new_content_view);
|
||||
cx.refresh();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
778
crates/ui/src/popup_menu.rs
Normal file
778
crates/ui/src/popup_menu.rs
Normal file
@@ -0,0 +1,778 @@
|
||||
use std::cell::Cell;
|
||||
use std::ops::Deref;
|
||||
use std::rc::Rc;
|
||||
|
||||
use gpui::{
|
||||
actions, div, prelude::FluentBuilder, px, Action, AppContext, DismissEvent, EventEmitter,
|
||||
FocusHandle, InteractiveElement, IntoElement, KeyBinding, ParentElement, Pixels, Render,
|
||||
SharedString, View, ViewContext, VisualContext as _, WindowContext,
|
||||
};
|
||||
use gpui::{
|
||||
anchored, canvas, rems, AnchorCorner, AnyElement, Bounds, Edges, FocusableView, Keystroke,
|
||||
ScrollHandle, StatefulInteractiveElement, Styled, WeakView,
|
||||
};
|
||||
|
||||
use crate::scroll::{Scrollbar, ScrollbarState};
|
||||
use crate::StyledExt;
|
||||
use crate::{
|
||||
button::Button, h_flex, list::ListItem, popover::Popover, theme::ActiveTheme, v_flex, Icon,
|
||||
IconName, Selectable, Sizable as _,
|
||||
};
|
||||
|
||||
actions!(menu, [Confirm, Dismiss, SelectNext, SelectPrev]);
|
||||
|
||||
pub fn init(cx: &mut AppContext) {
|
||||
let context = Some("PopupMenu");
|
||||
cx.bind_keys([
|
||||
KeyBinding::new("enter", Confirm, context),
|
||||
KeyBinding::new("escape", Dismiss, context),
|
||||
KeyBinding::new("up", SelectPrev, context),
|
||||
KeyBinding::new("down", SelectNext, context),
|
||||
]);
|
||||
}
|
||||
|
||||
pub trait PopupMenuExt: Styled + Selectable + IntoElement + 'static {
|
||||
/// Create a popup menu with the given items, anchored to the TopLeft corner
|
||||
fn popup_menu(
|
||||
self,
|
||||
f: impl Fn(PopupMenu, &mut ViewContext<PopupMenu>) -> PopupMenu + 'static,
|
||||
) -> Popover<PopupMenu> {
|
||||
self.popup_menu_with_anchor(AnchorCorner::TopLeft, f)
|
||||
}
|
||||
|
||||
/// Create a popup menu with the given items, anchored to the given corner
|
||||
fn popup_menu_with_anchor(
|
||||
mut self,
|
||||
anchor: impl Into<AnchorCorner>,
|
||||
f: impl Fn(PopupMenu, &mut ViewContext<PopupMenu>) -> PopupMenu + 'static,
|
||||
) -> Popover<PopupMenu> {
|
||||
let style = self.style().clone();
|
||||
let element_id = self.element_id();
|
||||
|
||||
Popover::new(SharedString::from(format!("popup-menu:{:?}", element_id)))
|
||||
.no_style()
|
||||
.trigger(self)
|
||||
.trigger_style(style)
|
||||
.anchor(anchor.into())
|
||||
.content(move |cx| PopupMenu::build(cx, |menu, cx| f(menu, cx)))
|
||||
}
|
||||
}
|
||||
impl PopupMenuExt for Button {}
|
||||
|
||||
enum PopupMenuItem {
|
||||
Separator,
|
||||
Item {
|
||||
icon: Option<Icon>,
|
||||
label: SharedString,
|
||||
action: Option<Box<dyn Action>>,
|
||||
handler: Rc<dyn Fn(&mut WindowContext)>,
|
||||
},
|
||||
ElementItem {
|
||||
render: Box<dyn Fn(&mut WindowContext) -> AnyElement + 'static>,
|
||||
handler: Rc<dyn Fn(&mut WindowContext)>,
|
||||
},
|
||||
Submenu {
|
||||
icon: Option<Icon>,
|
||||
label: SharedString,
|
||||
menu: View<PopupMenu>,
|
||||
},
|
||||
}
|
||||
|
||||
impl PopupMenuItem {
|
||||
fn is_clickable(&self) -> bool {
|
||||
!matches!(self, PopupMenuItem::Separator)
|
||||
}
|
||||
|
||||
fn is_separator(&self) -> bool {
|
||||
matches!(self, PopupMenuItem::Separator)
|
||||
}
|
||||
|
||||
fn has_icon(&self) -> bool {
|
||||
matches!(self, PopupMenuItem::Item { icon: Some(_), .. })
|
||||
}
|
||||
}
|
||||
|
||||
pub struct PopupMenu {
|
||||
/// The parent menu of this menu, if this is a submenu
|
||||
parent_menu: Option<WeakView<Self>>,
|
||||
focus_handle: FocusHandle,
|
||||
menu_items: Vec<PopupMenuItem>,
|
||||
has_icon: bool,
|
||||
selected_index: Option<usize>,
|
||||
min_width: Pixels,
|
||||
max_width: Pixels,
|
||||
hovered_menu_ix: Option<usize>,
|
||||
bounds: Bounds<Pixels>,
|
||||
|
||||
scrollable: bool,
|
||||
scroll_handle: ScrollHandle,
|
||||
scroll_state: Rc<Cell<ScrollbarState>>,
|
||||
|
||||
action_focus_handle: Option<FocusHandle>,
|
||||
_subscriptions: [gpui::Subscription; 1],
|
||||
}
|
||||
|
||||
impl PopupMenu {
|
||||
pub fn build(
|
||||
cx: &mut WindowContext,
|
||||
f: impl FnOnce(Self, &mut ViewContext<PopupMenu>) -> Self,
|
||||
) -> View<Self> {
|
||||
cx.new_view(|cx| {
|
||||
let focus_handle = cx.focus_handle();
|
||||
let _on_blur_subscription = cx.on_blur(&focus_handle, |this: &mut PopupMenu, cx| {
|
||||
this.dismiss(&Dismiss, cx)
|
||||
});
|
||||
|
||||
let menu = Self {
|
||||
focus_handle,
|
||||
action_focus_handle: None,
|
||||
parent_menu: None,
|
||||
menu_items: Vec::new(),
|
||||
selected_index: None,
|
||||
min_width: px(120.),
|
||||
max_width: px(500.),
|
||||
has_icon: false,
|
||||
hovered_menu_ix: None,
|
||||
bounds: Bounds::default(),
|
||||
scrollable: false,
|
||||
scroll_handle: ScrollHandle::default(),
|
||||
scroll_state: Rc::new(Cell::new(ScrollbarState::default())),
|
||||
_subscriptions: [_on_blur_subscription],
|
||||
};
|
||||
cx.refresh();
|
||||
f(menu, cx)
|
||||
})
|
||||
}
|
||||
|
||||
/// Bind the focus handle of the menu, when clicked, it will focus back to this handle and then dispatch the action
|
||||
pub fn track_focus(mut self, focus_handle: &FocusHandle) -> Self {
|
||||
self.action_focus_handle = Some(focus_handle.clone());
|
||||
self
|
||||
}
|
||||
|
||||
/// Set min width of the popup menu, default is 120px
|
||||
pub fn min_w(mut self, width: impl Into<Pixels>) -> Self {
|
||||
self.min_width = width.into();
|
||||
self
|
||||
}
|
||||
|
||||
/// Set max width of the popup menu, default is 500px
|
||||
pub fn max_w(mut self, width: impl Into<Pixels>) -> Self {
|
||||
self.max_width = width.into();
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the menu to be scrollable to show vertical scrollbar.
|
||||
///
|
||||
/// NOTE: If this is true, the sub-menus will cannot be support.
|
||||
pub fn scrollable(mut self) -> Self {
|
||||
self.scrollable = true;
|
||||
self
|
||||
}
|
||||
|
||||
/// Add Menu Item
|
||||
pub fn menu(mut self, label: impl Into<SharedString>, action: Box<dyn Action>) -> Self {
|
||||
self.add_menu_item(label, None, action);
|
||||
self
|
||||
}
|
||||
|
||||
/// Add Menu to open link
|
||||
pub fn link(mut self, label: impl Into<SharedString>, href: impl Into<String>) -> Self {
|
||||
let href = href.into();
|
||||
self.menu_items.push(PopupMenuItem::Item {
|
||||
icon: None,
|
||||
label: label.into(),
|
||||
action: None,
|
||||
handler: Rc::new(move |cx| cx.open_url(&href)),
|
||||
});
|
||||
self
|
||||
}
|
||||
|
||||
/// Add Menu to open link
|
||||
pub fn link_with_icon(
|
||||
mut self,
|
||||
label: impl Into<SharedString>,
|
||||
icon: impl Into<Icon>,
|
||||
href: impl Into<String>,
|
||||
) -> Self {
|
||||
let href = href.into();
|
||||
self.menu_items.push(PopupMenuItem::Item {
|
||||
icon: Some(icon.into()),
|
||||
label: label.into(),
|
||||
action: None,
|
||||
handler: Rc::new(move |cx| cx.open_url(&href)),
|
||||
});
|
||||
self
|
||||
}
|
||||
|
||||
/// Add Menu Item with Icon
|
||||
pub fn menu_with_icon(
|
||||
mut self,
|
||||
label: impl Into<SharedString>,
|
||||
icon: impl Into<Icon>,
|
||||
action: Box<dyn Action>,
|
||||
) -> Self {
|
||||
self.add_menu_item(label, Some(icon.into()), action);
|
||||
self
|
||||
}
|
||||
|
||||
/// Add Menu Item with check icon
|
||||
pub fn menu_with_check(
|
||||
mut self,
|
||||
label: impl Into<SharedString>,
|
||||
checked: bool,
|
||||
action: Box<dyn Action>,
|
||||
) -> Self {
|
||||
if checked {
|
||||
self.add_menu_item(label, Some(IconName::Check.into()), action);
|
||||
} else {
|
||||
self.add_menu_item(label, None, action);
|
||||
}
|
||||
|
||||
self
|
||||
}
|
||||
|
||||
/// Add Menu Item with custom element render.
|
||||
pub fn menu_with_element<F, E>(mut self, builder: F, action: Box<dyn Action>) -> Self
|
||||
where
|
||||
F: Fn(&mut WindowContext) -> E + 'static,
|
||||
E: IntoElement,
|
||||
{
|
||||
self.menu_items.push(PopupMenuItem::ElementItem {
|
||||
render: Box::new(move |cx| builder(cx).into_any_element()),
|
||||
handler: self.wrap_handler(action),
|
||||
});
|
||||
self
|
||||
}
|
||||
|
||||
fn wrap_handler(&self, action: Box<dyn Action>) -> Rc<dyn Fn(&mut WindowContext)> {
|
||||
let action_focus_handle = self.action_focus_handle.clone();
|
||||
|
||||
Rc::new(move |cx| {
|
||||
cx.activate_window();
|
||||
|
||||
// Focus back to the user expected focus handle
|
||||
// Then the actions listened on that focus handle can be received
|
||||
//
|
||||
// For example:
|
||||
//
|
||||
// TabPanel
|
||||
// |- PopupMenu
|
||||
// |- PanelContent (actions are listened here)
|
||||
//
|
||||
// The `PopupMenu` and `PanelContent` are at the same level in the TabPanel
|
||||
// If the actions are listened on the `PanelContent`,
|
||||
// it can't receive the actions from the `PopupMenu`, unless we focus on `PanelContent`.
|
||||
if let Some(handle) = action_focus_handle.as_ref() {
|
||||
cx.focus(&handle);
|
||||
}
|
||||
|
||||
cx.dispatch_action(action.boxed_clone());
|
||||
})
|
||||
}
|
||||
|
||||
fn add_menu_item(
|
||||
&mut self,
|
||||
label: impl Into<SharedString>,
|
||||
icon: Option<Icon>,
|
||||
action: Box<dyn Action>,
|
||||
) -> &mut Self {
|
||||
if icon.is_some() {
|
||||
self.has_icon = true;
|
||||
}
|
||||
|
||||
self.menu_items.push(PopupMenuItem::Item {
|
||||
icon,
|
||||
label: label.into(),
|
||||
action: Some(action.boxed_clone()),
|
||||
handler: self.wrap_handler(action),
|
||||
});
|
||||
self
|
||||
}
|
||||
|
||||
/// Add a separator Menu Item
|
||||
pub fn separator(mut self) -> Self {
|
||||
if self.menu_items.is_empty() {
|
||||
return self;
|
||||
}
|
||||
|
||||
if let Some(PopupMenuItem::Separator) = self.menu_items.last() {
|
||||
return self;
|
||||
}
|
||||
|
||||
self.menu_items.push(PopupMenuItem::Separator);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn submenu(
|
||||
self,
|
||||
label: impl Into<SharedString>,
|
||||
cx: &mut ViewContext<Self>,
|
||||
f: impl Fn(PopupMenu, &mut ViewContext<PopupMenu>) -> PopupMenu + 'static,
|
||||
) -> Self {
|
||||
self.submenu_with_icon(None, label, cx, f)
|
||||
}
|
||||
|
||||
/// Add a Submenu item with icon
|
||||
pub fn submenu_with_icon(
|
||||
mut self,
|
||||
icon: Option<Icon>,
|
||||
label: impl Into<SharedString>,
|
||||
cx: &mut ViewContext<Self>,
|
||||
f: impl Fn(PopupMenu, &mut ViewContext<PopupMenu>) -> PopupMenu + 'static,
|
||||
) -> Self {
|
||||
let submenu = PopupMenu::build(cx, f);
|
||||
let parent_menu = cx.view().downgrade();
|
||||
submenu.update(cx, |view, _| {
|
||||
view.parent_menu = Some(parent_menu);
|
||||
});
|
||||
|
||||
self.menu_items.push(PopupMenuItem::Submenu {
|
||||
icon,
|
||||
label: label.into(),
|
||||
menu: submenu,
|
||||
});
|
||||
self
|
||||
}
|
||||
|
||||
pub(crate) fn active_submenu(&self) -> Option<View<PopupMenu>> {
|
||||
if let Some(ix) = self.hovered_menu_ix {
|
||||
if let Some(item) = self.menu_items.get(ix) {
|
||||
return match item {
|
||||
PopupMenuItem::Submenu { menu, .. } => Some(menu.clone()),
|
||||
_ => None,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.menu_items.is_empty()
|
||||
}
|
||||
|
||||
fn clickable_menu_items(&self) -> impl Iterator<Item = (usize, &PopupMenuItem)> {
|
||||
self.menu_items
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, item)| item.is_clickable())
|
||||
}
|
||||
|
||||
fn on_click(&mut self, ix: usize, cx: &mut ViewContext<Self>) {
|
||||
cx.stop_propagation();
|
||||
cx.prevent_default();
|
||||
self.selected_index = Some(ix);
|
||||
self.confirm(&Confirm, cx);
|
||||
}
|
||||
|
||||
fn confirm(&mut self, _: &Confirm, cx: &mut ViewContext<Self>) {
|
||||
match self.selected_index {
|
||||
Some(index) => {
|
||||
let item = self.menu_items.get(index);
|
||||
match item {
|
||||
Some(PopupMenuItem::Item { handler, .. }) => {
|
||||
handler(cx);
|
||||
self.dismiss(&Dismiss, cx)
|
||||
}
|
||||
Some(PopupMenuItem::ElementItem { handler, .. }) => {
|
||||
handler(cx);
|
||||
self.dismiss(&Dismiss, cx)
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn select_next(&mut self, _: &SelectNext, cx: &mut ViewContext<Self>) {
|
||||
let count = self.clickable_menu_items().count();
|
||||
if count > 0 {
|
||||
let ix = self
|
||||
.selected_index
|
||||
.map(|index| if index == count - 1 { 0 } else { index + 1 })
|
||||
.unwrap_or(0);
|
||||
|
||||
self.selected_index = Some(ix);
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
fn select_prev(&mut self, _: &SelectPrev, cx: &mut ViewContext<Self>) {
|
||||
let count = self.clickable_menu_items().count();
|
||||
if count > 0 {
|
||||
let ix = self
|
||||
.selected_index
|
||||
.map(|index| if index == count - 1 { 0 } else { index - 1 })
|
||||
.unwrap_or(count - 1);
|
||||
self.selected_index = Some(ix);
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
fn dismiss(&mut self, _: &Dismiss, cx: &mut ViewContext<Self>) {
|
||||
if self.active_submenu().is_some() {
|
||||
return;
|
||||
}
|
||||
|
||||
cx.emit(DismissEvent);
|
||||
// Dismiss parent menu, when this menu is dismissed
|
||||
if let Some(parent_menu) = self.parent_menu.clone().and_then(|menu| menu.upgrade()) {
|
||||
parent_menu.update(cx, |view, cx| {
|
||||
view.hovered_menu_ix = None;
|
||||
view.dismiss(&Dismiss, cx);
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn render_keybinding(
|
||||
action: Option<Box<dyn Action>>,
|
||||
cx: &ViewContext<Self>,
|
||||
) -> Option<impl IntoElement> {
|
||||
if let Some(action) = action {
|
||||
if let Some(keybinding) = cx.bindings_for_action(action.deref()).first() {
|
||||
let el = div().text_color(cx.theme().muted_foreground).children(
|
||||
keybinding
|
||||
.keystrokes()
|
||||
.into_iter()
|
||||
.map(|key| key_shortcut(key.clone())),
|
||||
);
|
||||
|
||||
return Some(el);
|
||||
}
|
||||
}
|
||||
|
||||
return None;
|
||||
}
|
||||
|
||||
fn render_icon(
|
||||
has_icon: bool,
|
||||
icon: Option<Icon>,
|
||||
_: &ViewContext<Self>,
|
||||
) -> Option<impl IntoElement> {
|
||||
let icon_placeholder = if has_icon { Some(Icon::empty()) } else { None };
|
||||
|
||||
if !has_icon {
|
||||
return None;
|
||||
}
|
||||
|
||||
let icon = h_flex()
|
||||
.w_3p5()
|
||||
.h_3p5()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.text_sm()
|
||||
.map(|this| {
|
||||
if let Some(icon) = icon {
|
||||
this.child(icon.clone().small())
|
||||
} else {
|
||||
this.children(icon_placeholder.clone())
|
||||
}
|
||||
});
|
||||
|
||||
Some(icon)
|
||||
}
|
||||
}
|
||||
|
||||
impl FluentBuilder for PopupMenu {}
|
||||
impl EventEmitter<DismissEvent> for PopupMenu {}
|
||||
impl FocusableView for PopupMenu {
|
||||
fn focus_handle(&self, _: &AppContext) -> FocusHandle {
|
||||
self.focus_handle.clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for PopupMenu {
|
||||
fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
|
||||
let view = cx.view().clone();
|
||||
let has_icon = self.menu_items.iter().any(|item| item.has_icon());
|
||||
let items_count = self.menu_items.len();
|
||||
let max_width = self.max_width;
|
||||
let bounds = self.bounds;
|
||||
|
||||
let window_haft_height = cx.window_bounds().get_bounds().size.height * 0.5;
|
||||
let max_height = window_haft_height.min(px(450.));
|
||||
|
||||
const ITEM_HEIGHT: Pixels = px(26.);
|
||||
|
||||
v_flex()
|
||||
.id("popup-menu")
|
||||
.key_context("PopupMenu")
|
||||
.track_focus(&self.focus_handle)
|
||||
.on_action(cx.listener(Self::select_next))
|
||||
.on_action(cx.listener(Self::select_prev))
|
||||
.on_action(cx.listener(Self::confirm))
|
||||
.on_action(cx.listener(Self::dismiss))
|
||||
.on_mouse_down_out(cx.listener(|this, _, cx| this.dismiss(&Dismiss, cx)))
|
||||
.popover_style(cx)
|
||||
.text_color(cx.theme().popover_foreground)
|
||||
.relative()
|
||||
.p_1()
|
||||
.child(
|
||||
div()
|
||||
.id("popup-menu-items")
|
||||
.when(self.scrollable, |this| {
|
||||
this.max_h(max_height)
|
||||
.overflow_y_scroll()
|
||||
.track_scroll(&self.scroll_handle)
|
||||
})
|
||||
.child(
|
||||
v_flex()
|
||||
.gap_y_0p5()
|
||||
.min_w(self.min_width)
|
||||
.max_w(self.max_width)
|
||||
.min_w(rems(8.))
|
||||
.child({
|
||||
canvas(
|
||||
move |bounds, cx| view.update(cx, |r, _| r.bounds = bounds),
|
||||
|_, _, _| {},
|
||||
)
|
||||
.absolute()
|
||||
.size_full()
|
||||
})
|
||||
.children(
|
||||
self.menu_items
|
||||
.iter_mut()
|
||||
.enumerate()
|
||||
// Skip last separator
|
||||
.filter(|(ix, item)| {
|
||||
!(*ix == items_count - 1 && item.is_separator())
|
||||
})
|
||||
.map(|(ix, item)| {
|
||||
let this = ListItem::new(("menu-item", ix))
|
||||
.relative()
|
||||
.text_sm()
|
||||
.py_0()
|
||||
.px_2()
|
||||
.rounded_md()
|
||||
.items_center()
|
||||
.on_mouse_enter(cx.listener(move |this, _, cx| {
|
||||
this.hovered_menu_ix = Some(ix);
|
||||
cx.notify();
|
||||
}));
|
||||
|
||||
match item {
|
||||
PopupMenuItem::Separator => {
|
||||
this.h_auto().p_0().disabled(true).child(
|
||||
div()
|
||||
.rounded_none()
|
||||
.h(px(1.))
|
||||
.mx_neg_1()
|
||||
.my_0p5()
|
||||
.bg(cx.theme().muted),
|
||||
)
|
||||
}
|
||||
PopupMenuItem::ElementItem { render, .. } => this
|
||||
.on_click(cx.listener(move |this, _, cx| {
|
||||
this.on_click(ix, cx)
|
||||
}))
|
||||
.child(
|
||||
h_flex()
|
||||
.min_h(ITEM_HEIGHT)
|
||||
.items_center()
|
||||
.gap_x_1p5()
|
||||
.children(Self::render_icon(
|
||||
has_icon, None, cx,
|
||||
))
|
||||
.child((render)(cx)),
|
||||
),
|
||||
PopupMenuItem::Item {
|
||||
icon,
|
||||
label,
|
||||
action,
|
||||
..
|
||||
} => {
|
||||
let action = action
|
||||
.as_ref()
|
||||
.map(|action| action.boxed_clone());
|
||||
let key = Self::render_keybinding(action, cx);
|
||||
|
||||
this.on_click(cx.listener(move |this, _, cx| {
|
||||
this.on_click(ix, cx)
|
||||
}))
|
||||
.child(
|
||||
h_flex()
|
||||
.h(ITEM_HEIGHT)
|
||||
.items_center()
|
||||
.gap_x_1p5()
|
||||
.children(Self::render_icon(
|
||||
has_icon,
|
||||
icon.clone(),
|
||||
cx,
|
||||
))
|
||||
.child(
|
||||
h_flex()
|
||||
.flex_1()
|
||||
.gap_2()
|
||||
.items_center()
|
||||
.justify_between()
|
||||
.child(label.clone())
|
||||
.children(key),
|
||||
),
|
||||
)
|
||||
}
|
||||
PopupMenuItem::Submenu { icon, label, menu } => this
|
||||
.when(self.hovered_menu_ix == Some(ix), |this| {
|
||||
this.selected(true)
|
||||
})
|
||||
.child(
|
||||
h_flex()
|
||||
.items_start()
|
||||
.child(
|
||||
h_flex()
|
||||
.size_full()
|
||||
.items_center()
|
||||
.gap_x_1p5()
|
||||
.children(Self::render_icon(
|
||||
has_icon,
|
||||
icon.clone(),
|
||||
cx,
|
||||
))
|
||||
.child(
|
||||
h_flex()
|
||||
.flex_1()
|
||||
.gap_2()
|
||||
.items_center()
|
||||
.justify_between()
|
||||
.child(label.clone())
|
||||
.child(
|
||||
IconName::ChevronRight,
|
||||
),
|
||||
),
|
||||
)
|
||||
.when_some(
|
||||
self.hovered_menu_ix,
|
||||
|this, hovered_ix| {
|
||||
let (anchor, left) =
|
||||
if cx.bounds().size.width
|
||||
- bounds.origin.x
|
||||
< max_width
|
||||
{
|
||||
(
|
||||
AnchorCorner::TopRight,
|
||||
-px(15.),
|
||||
)
|
||||
} else {
|
||||
(
|
||||
AnchorCorner::TopLeft,
|
||||
bounds.size.width
|
||||
- px(10.),
|
||||
)
|
||||
};
|
||||
|
||||
let top = if bounds.origin.y
|
||||
+ bounds.size.height
|
||||
> cx.bounds().size.height
|
||||
{
|
||||
px(32.)
|
||||
} else {
|
||||
-px(10.)
|
||||
};
|
||||
|
||||
if hovered_ix == ix {
|
||||
this.child(
|
||||
anchored()
|
||||
.anchor(anchor)
|
||||
.child(
|
||||
div()
|
||||
.occlude()
|
||||
.top(top)
|
||||
.left(left)
|
||||
.child(menu.clone()),
|
||||
)
|
||||
.snap_to_window_with_margin(
|
||||
Edges::all(px(8.)),
|
||||
),
|
||||
)
|
||||
} else {
|
||||
this
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
}
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
.when(self.scrollable, |this| {
|
||||
// TODO: When the menu is limited by `overflow_y_scroll`, the sub-menu will cannot be displayed.
|
||||
this.child(
|
||||
div()
|
||||
.absolute()
|
||||
.top_0()
|
||||
.left_0()
|
||||
.right_0p5()
|
||||
.bottom_0()
|
||||
.child(Scrollbar::vertical(
|
||||
cx.entity_id(),
|
||||
self.scroll_state.clone(),
|
||||
self.scroll_handle.clone(),
|
||||
self.bounds.size,
|
||||
)),
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Return the Platform specific keybinding string by KeyStroke
|
||||
pub fn key_shortcut(key: Keystroke) -> String {
|
||||
if cfg!(target_os = "macos") {
|
||||
return format!("{}", key);
|
||||
}
|
||||
|
||||
let mut parts = vec![];
|
||||
if key.modifiers.control {
|
||||
parts.push("Ctrl");
|
||||
}
|
||||
if key.modifiers.alt {
|
||||
parts.push("Alt");
|
||||
}
|
||||
if key.modifiers.platform {
|
||||
parts.push("Win");
|
||||
}
|
||||
if key.modifiers.shift {
|
||||
parts.push("Shift");
|
||||
}
|
||||
|
||||
// Capitalize the first letter
|
||||
let key = if let Some(first_c) = key.key.chars().next() {
|
||||
format!("{}{}", first_c.to_uppercase(), &key.key[1..])
|
||||
} else {
|
||||
key.key.to_string()
|
||||
};
|
||||
|
||||
parts.push(&key);
|
||||
parts.join("+")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn test_key_shortcut() {
|
||||
use super::key_shortcut;
|
||||
use gpui::Keystroke;
|
||||
|
||||
if cfg!(target_os = "windows") {
|
||||
assert_eq!(key_shortcut(Keystroke::parse("a").unwrap()), "A");
|
||||
assert_eq!(key_shortcut(Keystroke::parse("ctrl-a").unwrap()), "Ctrl+A");
|
||||
assert_eq!(
|
||||
key_shortcut(Keystroke::parse("ctrl-alt-a").unwrap()),
|
||||
"Ctrl+Alt+A"
|
||||
);
|
||||
assert_eq!(
|
||||
key_shortcut(Keystroke::parse("ctrl-alt-shift-a").unwrap()),
|
||||
"Ctrl+Alt+Shift+A"
|
||||
);
|
||||
assert_eq!(
|
||||
key_shortcut(Keystroke::parse("ctrl-alt-shift-win-a").unwrap()),
|
||||
"Ctrl+Alt+Win+Shift+A"
|
||||
);
|
||||
assert_eq!(
|
||||
key_shortcut(Keystroke::parse("ctrl-shift-backspace").unwrap()),
|
||||
"Ctrl+Shift+Backspace"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
9
crates/ui/src/prelude.rs
Normal file
9
crates/ui/src/prelude.rs
Normal file
@@ -0,0 +1,9 @@
|
||||
//! The prelude of this crate. When building UI in Zed you almost always want to import this.
|
||||
|
||||
pub use gpui::prelude::*;
|
||||
#[allow(unused_imports)]
|
||||
pub use gpui::{
|
||||
div, px, relative, rems, AbsoluteLength, DefiniteLength, Div, Element, ElementId,
|
||||
InteractiveElement, ParentElement, Pixels, Rems, RenderOnce, SharedString, Styled, ViewContext,
|
||||
WindowContext,
|
||||
};
|
||||
56
crates/ui/src/progress.rs
Normal file
56
crates/ui/src/progress.rs
Normal file
@@ -0,0 +1,56 @@
|
||||
use crate::theme::ActiveTheme;
|
||||
use gpui::{
|
||||
div, prelude::FluentBuilder, px, relative, IntoElement, ParentElement, RenderOnce, Styled,
|
||||
WindowContext,
|
||||
};
|
||||
|
||||
/// A Progress bar element.
|
||||
#[derive(IntoElement)]
|
||||
pub struct Progress {
|
||||
value: f32,
|
||||
height: f32,
|
||||
}
|
||||
|
||||
impl Progress {
|
||||
pub fn new() -> Self {
|
||||
Progress {
|
||||
value: Default::default(),
|
||||
height: 8.,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn value(mut self, value: f32) -> Self {
|
||||
self.value = value;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for Progress {
|
||||
fn render(self, cx: &mut WindowContext) -> impl IntoElement {
|
||||
let rounded = px(self.height / 2.);
|
||||
let relative_w = relative(match self.value {
|
||||
v if v < 0. => 0.,
|
||||
v if v > 100. => 1.,
|
||||
v => v / 100.,
|
||||
});
|
||||
|
||||
div()
|
||||
.relative()
|
||||
.h(px(self.height))
|
||||
.rounded(rounded)
|
||||
.bg(cx.theme().progress_bar.opacity(0.2))
|
||||
.child(
|
||||
div()
|
||||
.absolute()
|
||||
.top_0()
|
||||
.left_0()
|
||||
.h_full()
|
||||
.w(relative_w)
|
||||
.bg(cx.theme().progress_bar)
|
||||
.map(|this| match self.value {
|
||||
v if v >= 100. => this.rounded(rounded),
|
||||
_ => this.rounded_l(rounded),
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
109
crates/ui/src/radio.rs
Normal file
109
crates/ui/src/radio.rs
Normal file
@@ -0,0 +1,109 @@
|
||||
use crate::{h_flex, theme::ActiveTheme, IconName};
|
||||
use gpui::{
|
||||
div, prelude::FluentBuilder, relative, svg, ElementId, InteractiveElement, IntoElement,
|
||||
ParentElement, RenderOnce, SharedString, StatefulInteractiveElement, Styled, WindowContext,
|
||||
};
|
||||
|
||||
/// A Radio element.
|
||||
///
|
||||
/// This is not included the Radio group implementation, you can manage the group by yourself.
|
||||
#[derive(IntoElement)]
|
||||
pub struct Radio {
|
||||
id: ElementId,
|
||||
label: Option<SharedString>,
|
||||
checked: bool,
|
||||
disabled: bool,
|
||||
on_click: Option<Box<dyn Fn(&bool, &mut WindowContext) + 'static>>,
|
||||
}
|
||||
|
||||
impl Radio {
|
||||
pub fn new(id: impl Into<ElementId>) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
label: None,
|
||||
checked: false,
|
||||
disabled: false,
|
||||
on_click: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn label(mut self, label: impl Into<SharedString>) -> Self {
|
||||
self.label = Some(label.into());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn checked(mut self, checked: bool) -> Self {
|
||||
self.checked = checked;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn disabled(mut self, disabled: bool) -> Self {
|
||||
self.disabled = disabled;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn on_click(mut self, handler: impl Fn(&bool, &mut WindowContext) + 'static) -> Self {
|
||||
self.on_click = Some(Box::new(handler));
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for Radio {
|
||||
fn render(self, cx: &mut WindowContext) -> impl IntoElement {
|
||||
let color = if self.disabled {
|
||||
cx.theme().primary.opacity(0.5)
|
||||
} else {
|
||||
cx.theme().primary
|
||||
};
|
||||
|
||||
h_flex()
|
||||
.id(self.id)
|
||||
.gap_x_2()
|
||||
.text_color(cx.theme().foreground)
|
||||
.items_center()
|
||||
.line_height(relative(1.))
|
||||
.child(
|
||||
div()
|
||||
.relative()
|
||||
.size_4()
|
||||
.flex_shrink_0()
|
||||
.rounded_full()
|
||||
.border_1()
|
||||
.border_color(color)
|
||||
.when(self.checked, |this| this.bg(color))
|
||||
.child(
|
||||
svg()
|
||||
.absolute()
|
||||
.top_px()
|
||||
.left_px()
|
||||
.size_3()
|
||||
.text_color(color)
|
||||
.when(self.checked, |this| {
|
||||
this.text_color(cx.theme().primary_foreground)
|
||||
})
|
||||
.map(|this| match self.checked {
|
||||
true => this.path(IconName::Check.path()),
|
||||
false => this,
|
||||
}),
|
||||
),
|
||||
)
|
||||
.when_some(self.label, |this, label| {
|
||||
this.child(
|
||||
div()
|
||||
.size_full()
|
||||
.overflow_x_hidden()
|
||||
.text_ellipsis()
|
||||
.line_height(relative(1.))
|
||||
.child(label),
|
||||
)
|
||||
})
|
||||
.when_some(
|
||||
self.on_click.filter(|_| !self.disabled),
|
||||
|this, on_click| {
|
||||
this.on_click(move |_event, cx| {
|
||||
on_click(&!self.checked, cx);
|
||||
})
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
18
crates/ui/src/resizable/mod.rs
Normal file
18
crates/ui/src/resizable/mod.rs
Normal file
@@ -0,0 +1,18 @@
|
||||
use gpui::{Axis, ViewContext};
|
||||
|
||||
mod panel;
|
||||
mod resize_handle;
|
||||
pub use panel::*;
|
||||
pub(crate) use resize_handle::*;
|
||||
|
||||
pub fn h_resizable(cx: &mut ViewContext<ResizablePanelGroup>) -> ResizablePanelGroup {
|
||||
ResizablePanelGroup::new(cx).axis(Axis::Horizontal)
|
||||
}
|
||||
|
||||
pub fn v_resizable(cx: &mut ViewContext<ResizablePanelGroup>) -> ResizablePanelGroup {
|
||||
ResizablePanelGroup::new(cx).axis(Axis::Vertical)
|
||||
}
|
||||
|
||||
pub fn resizable_panel() -> ResizablePanel {
|
||||
ResizablePanel::new()
|
||||
}
|
||||
487
crates/ui/src/resizable/panel.rs
Normal file
487
crates/ui/src/resizable/panel.rs
Normal file
@@ -0,0 +1,487 @@
|
||||
use std::rc::Rc;
|
||||
|
||||
use gpui::{
|
||||
canvas, div, prelude::FluentBuilder, px, relative, Along, AnyElement, AnyView, Axis, Bounds,
|
||||
Element, Entity, EntityId, EventEmitter, IntoElement, IsZero, MouseMoveEvent, MouseUpEvent,
|
||||
ParentElement, Pixels, Render, StatefulInteractiveElement as _, Style, Styled, View,
|
||||
ViewContext, VisualContext as _, WeakView, WindowContext,
|
||||
};
|
||||
|
||||
use crate::{h_flex, v_flex, AxisExt};
|
||||
|
||||
use super::resize_handle;
|
||||
|
||||
pub(crate) const PANEL_MIN_SIZE: Pixels = px(100.);
|
||||
|
||||
pub enum ResizablePanelEvent {
|
||||
Resized,
|
||||
}
|
||||
|
||||
#[derive(Clone, Render)]
|
||||
pub struct DragPanel(pub (EntityId, usize, Axis));
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct ResizablePanelGroup {
|
||||
panels: Vec<View<ResizablePanel>>,
|
||||
sizes: Vec<Pixels>,
|
||||
axis: Axis,
|
||||
size: Option<Pixels>,
|
||||
bounds: Bounds<Pixels>,
|
||||
resizing_panel_ix: Option<usize>,
|
||||
}
|
||||
|
||||
impl ResizablePanelGroup {
|
||||
pub(super) fn new(_cx: &mut ViewContext<Self>) -> Self {
|
||||
Self {
|
||||
axis: Axis::Horizontal,
|
||||
sizes: Vec::new(),
|
||||
panels: Vec::new(),
|
||||
size: None,
|
||||
bounds: Bounds::default(),
|
||||
resizing_panel_ix: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn load(&mut self, sizes: Vec<Pixels>, panels: Vec<View<ResizablePanel>>) {
|
||||
self.sizes = sizes;
|
||||
self.panels = panels;
|
||||
}
|
||||
|
||||
/// Set the axis of the resizable panel group, default is horizontal.
|
||||
pub fn axis(mut self, axis: Axis) -> Self {
|
||||
self.axis = axis;
|
||||
self
|
||||
}
|
||||
|
||||
pub(crate) fn set_axis(&mut self, axis: Axis, cx: &mut ViewContext<Self>) {
|
||||
self.axis = axis;
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Add a resizable panel to the group.
|
||||
pub fn child(mut self, panel: ResizablePanel, cx: &mut ViewContext<Self>) -> Self {
|
||||
self.add_child(panel, cx);
|
||||
self
|
||||
}
|
||||
|
||||
/// Add a ResizablePanelGroup as a child to the group.
|
||||
pub fn group(self, group: ResizablePanelGroup, cx: &mut ViewContext<Self>) -> Self {
|
||||
let group: ResizablePanelGroup = group;
|
||||
let size = group.size;
|
||||
let panel = ResizablePanel::new()
|
||||
.content_view(cx.new_view(|_| group).into())
|
||||
.when_some(size, |this, size| this.size(size));
|
||||
self.child(panel, cx)
|
||||
}
|
||||
|
||||
/// Set size of the resizable panel group
|
||||
///
|
||||
/// - When the axis is horizontal, the size is the height of the group.
|
||||
/// - When the axis is vertical, the size is the width of the group.
|
||||
pub fn size(mut self, size: Pixels) -> Self {
|
||||
self.size = Some(size);
|
||||
self
|
||||
}
|
||||
|
||||
/// Returns the sizes of the resizable panels.
|
||||
pub(crate) fn sizes(&self) -> Vec<Pixels> {
|
||||
self.sizes.clone()
|
||||
}
|
||||
|
||||
/// Calculates the sum of all panel sizes within the group.
|
||||
pub fn total_size(&self) -> Pixels {
|
||||
self.sizes.iter().fold(px(0.0), |acc, &size| acc + size)
|
||||
}
|
||||
|
||||
pub fn add_child(&mut self, panel: ResizablePanel, cx: &mut ViewContext<Self>) {
|
||||
let mut panel = panel;
|
||||
panel.axis = self.axis;
|
||||
panel.group = Some(cx.view().downgrade());
|
||||
self.sizes.push(panel.initial_size.unwrap_or_default());
|
||||
self.panels.push(cx.new_view(|_| panel));
|
||||
}
|
||||
|
||||
pub fn insert_child(&mut self, panel: ResizablePanel, ix: usize, cx: &mut ViewContext<Self>) {
|
||||
let mut panel = panel;
|
||||
panel.axis = self.axis;
|
||||
panel.group = Some(cx.view().downgrade());
|
||||
|
||||
self.sizes
|
||||
.insert(ix, panel.initial_size.unwrap_or_default());
|
||||
self.panels.insert(ix, cx.new_view(|_| panel));
|
||||
cx.notify()
|
||||
}
|
||||
|
||||
/// Replace a child panel with a new panel at the given index.
|
||||
pub(crate) fn replace_child(
|
||||
&mut self,
|
||||
panel: ResizablePanel,
|
||||
ix: usize,
|
||||
cx: &mut ViewContext<Self>,
|
||||
) {
|
||||
let mut panel = panel;
|
||||
|
||||
let old_panel = self.panels[ix].clone();
|
||||
let old_panel_initial_size = old_panel.read(cx).initial_size;
|
||||
let old_panel_size_ratio = old_panel.read(cx).size_ratio;
|
||||
|
||||
panel.initial_size = old_panel_initial_size;
|
||||
panel.size_ratio = old_panel_size_ratio;
|
||||
panel.axis = self.axis;
|
||||
panel.group = Some(cx.view().downgrade());
|
||||
self.sizes[ix] = panel.initial_size.unwrap_or_default();
|
||||
self.panels[ix] = cx.new_view(|_| panel);
|
||||
cx.notify()
|
||||
}
|
||||
|
||||
pub fn remove_child(&mut self, ix: usize, cx: &mut ViewContext<Self>) {
|
||||
self.sizes.remove(ix);
|
||||
self.panels.remove(ix);
|
||||
cx.notify()
|
||||
}
|
||||
|
||||
pub(crate) fn remove_all_children(&mut self, cx: &mut ViewContext<Self>) {
|
||||
self.sizes.clear();
|
||||
self.panels.clear();
|
||||
cx.notify()
|
||||
}
|
||||
|
||||
fn render_resize_handle(&self, ix: usize, cx: &mut ViewContext<Self>) -> impl IntoElement {
|
||||
let view = cx.view().clone();
|
||||
resize_handle(("resizable-handle", ix), self.axis).on_drag(
|
||||
DragPanel((cx.entity_id(), ix, self.axis)),
|
||||
move |drag_panel, _, cx| {
|
||||
cx.stop_propagation();
|
||||
// Set current resizing panel ix
|
||||
view.update(cx, |view, _| {
|
||||
view.resizing_panel_ix = Some(ix);
|
||||
});
|
||||
cx.new_view(|_| drag_panel.clone())
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fn done_resizing(&mut self, cx: &mut ViewContext<Self>) {
|
||||
cx.emit(ResizablePanelEvent::Resized);
|
||||
self.resizing_panel_ix = None;
|
||||
}
|
||||
|
||||
fn sync_real_panel_sizes(&mut self, cx: &WindowContext) {
|
||||
for (i, panel) in self.panels.iter().enumerate() {
|
||||
self.sizes[i] = panel.read(cx).bounds.size.along(self.axis)
|
||||
}
|
||||
}
|
||||
|
||||
/// The `ix`` is the index of the panel to resize,
|
||||
/// and the `size` is the new size for the panel.
|
||||
fn resize_panels(&mut self, ix: usize, size: Pixels, cx: &mut ViewContext<Self>) {
|
||||
let mut ix = ix;
|
||||
// Only resize the left panels.
|
||||
if ix >= self.panels.len() - 1 {
|
||||
return;
|
||||
}
|
||||
let size = size.floor();
|
||||
let container_size = self.bounds.size.along(self.axis);
|
||||
|
||||
self.sync_real_panel_sizes(cx);
|
||||
|
||||
let mut changed = size - self.sizes[ix];
|
||||
let is_expand = changed > px(0.);
|
||||
|
||||
let main_ix = ix;
|
||||
let mut new_sizes = self.sizes.clone();
|
||||
|
||||
if is_expand {
|
||||
new_sizes[ix] = size;
|
||||
|
||||
// Now to expand logic is correct.
|
||||
while changed > px(0.) && ix < self.panels.len() - 1 {
|
||||
ix += 1;
|
||||
let available_size = (new_sizes[ix] - PANEL_MIN_SIZE).max(px(0.));
|
||||
let to_reduce = changed.min(available_size);
|
||||
new_sizes[ix] -= to_reduce;
|
||||
changed -= to_reduce;
|
||||
}
|
||||
} else {
|
||||
let new_size = size.max(PANEL_MIN_SIZE);
|
||||
new_sizes[ix] = new_size;
|
||||
changed = size - PANEL_MIN_SIZE;
|
||||
new_sizes[ix + 1] += self.sizes[ix] - new_size;
|
||||
|
||||
while changed < px(0.) && ix > 0 {
|
||||
ix -= 1;
|
||||
let available_size = self.sizes[ix] - PANEL_MIN_SIZE;
|
||||
let to_increase = (changed).min(available_size);
|
||||
new_sizes[ix] += to_increase;
|
||||
changed += to_increase;
|
||||
}
|
||||
}
|
||||
|
||||
// If total size exceeds container size, adjust the main panel
|
||||
let total_size: Pixels = new_sizes.iter().map(|s| s.0).sum::<f32>().into();
|
||||
if total_size > container_size {
|
||||
let overflow = total_size - container_size;
|
||||
new_sizes[main_ix] = (new_sizes[main_ix] - overflow).max(PANEL_MIN_SIZE);
|
||||
}
|
||||
|
||||
let total_size = new_sizes.iter().fold(px(0.0), |acc, &size| acc + size);
|
||||
self.sizes = new_sizes;
|
||||
for (i, panel) in self.panels.iter().enumerate() {
|
||||
let size = self.sizes[i];
|
||||
if size > px(0.) {
|
||||
panel.update(cx, |this, _| {
|
||||
this.size = Some(size);
|
||||
this.size_ratio = Some(size / total_size);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
impl EventEmitter<ResizablePanelEvent> for ResizablePanelGroup {}
|
||||
impl Render for ResizablePanelGroup {
|
||||
fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
|
||||
let view = cx.view().clone();
|
||||
let container = if self.axis.is_horizontal() {
|
||||
h_flex()
|
||||
} else {
|
||||
v_flex()
|
||||
};
|
||||
|
||||
container
|
||||
.size_full()
|
||||
.children(self.panels.iter().enumerate().map(|(ix, panel)| {
|
||||
if ix > 0 {
|
||||
let handle = self.render_resize_handle(ix - 1, cx);
|
||||
panel.update(cx, |view, _| {
|
||||
view.resize_handle = Some(handle.into_any_element())
|
||||
});
|
||||
}
|
||||
|
||||
panel.clone()
|
||||
}))
|
||||
.child({
|
||||
canvas(
|
||||
move |bounds, cx| view.update(cx, |r, _| r.bounds = bounds),
|
||||
|_, _, _| {},
|
||||
)
|
||||
.absolute()
|
||||
.size_full()
|
||||
})
|
||||
.child(ResizePanelGroupElement {
|
||||
view: cx.view().clone(),
|
||||
axis: self.axis,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub struct ResizablePanel {
|
||||
group: Option<WeakView<ResizablePanelGroup>>,
|
||||
/// Initial size is the size that the panel has when it is created.
|
||||
initial_size: Option<Pixels>,
|
||||
/// size is the size that the panel has when it is resized or adjusted by flex layout.
|
||||
size: Option<Pixels>,
|
||||
/// the size ratio that the panel has relative to its group
|
||||
size_ratio: Option<f32>,
|
||||
axis: Axis,
|
||||
content_builder: Option<Rc<dyn Fn(&mut WindowContext) -> AnyElement>>,
|
||||
content_view: Option<AnyView>,
|
||||
/// The bounds of the resizable panel, when render the bounds will be updated.
|
||||
bounds: Bounds<Pixels>,
|
||||
resize_handle: Option<AnyElement>,
|
||||
}
|
||||
|
||||
impl ResizablePanel {
|
||||
pub(super) fn new() -> Self {
|
||||
Self {
|
||||
group: None,
|
||||
initial_size: None,
|
||||
size: None,
|
||||
size_ratio: None,
|
||||
axis: Axis::Horizontal,
|
||||
content_builder: None,
|
||||
content_view: None,
|
||||
bounds: Bounds::default(),
|
||||
resize_handle: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn content<F>(mut self, content: F) -> Self
|
||||
where
|
||||
F: Fn(&mut WindowContext) -> AnyElement + 'static,
|
||||
{
|
||||
self.content_builder = Some(Rc::new(content));
|
||||
self
|
||||
}
|
||||
|
||||
pub fn content_view(mut self, content: AnyView) -> Self {
|
||||
self.content_view = Some(content);
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the initial size of the panel.
|
||||
pub fn size(mut self, size: Pixels) -> Self {
|
||||
self.initial_size = Some(size);
|
||||
self
|
||||
}
|
||||
|
||||
/// Save the real panel size, and update group sizes
|
||||
fn update_size(&mut self, bounds: Bounds<Pixels>, cx: &mut ViewContext<Self>) {
|
||||
let new_size = bounds.size.along(self.axis);
|
||||
self.bounds = bounds;
|
||||
self.size = Some(new_size);
|
||||
|
||||
let panel_view = cx.view().clone();
|
||||
if let Some(group) = self.group.as_ref() {
|
||||
_ = group.update(cx, |view, _| {
|
||||
if let Some(ix) = view
|
||||
.panels
|
||||
.iter()
|
||||
.position(|v| v.entity_id() == panel_view.entity_id())
|
||||
{
|
||||
view.sizes[ix] = new_size;
|
||||
}
|
||||
});
|
||||
}
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
impl FluentBuilder for ResizablePanel {}
|
||||
|
||||
impl Render for ResizablePanel {
|
||||
fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
|
||||
let view = cx.view().clone();
|
||||
let total_size = self
|
||||
.group
|
||||
.as_ref()
|
||||
.and_then(|group| group.upgrade())
|
||||
.map(|group| group.read(cx).total_size());
|
||||
|
||||
div()
|
||||
.flex()
|
||||
.flex_grow()
|
||||
.size_full()
|
||||
.relative()
|
||||
.when(self.initial_size.is_none(), |this| this.flex_shrink())
|
||||
.when(self.axis.is_vertical(), |this| this.min_h(PANEL_MIN_SIZE))
|
||||
.when(self.axis.is_horizontal(), |this| this.min_w(PANEL_MIN_SIZE))
|
||||
.when_some(self.initial_size, |this, size| {
|
||||
if size.is_zero() {
|
||||
this
|
||||
} else {
|
||||
// The `self.size` is None, that mean the initial size for the panel, so we need set flex_shrink_0
|
||||
// To let it keep the initial size.
|
||||
this.when(self.size.is_none() && size > px(0.), |this| {
|
||||
this.flex_shrink_0()
|
||||
})
|
||||
.flex_basis(size)
|
||||
}
|
||||
})
|
||||
.map(|this| match (self.size_ratio, self.size, total_size) {
|
||||
(Some(size_ratio), _, _) => this.flex_basis(relative(size_ratio)),
|
||||
(None, Some(size), Some(total_size)) => {
|
||||
this.flex_basis(relative(size / total_size))
|
||||
}
|
||||
(None, Some(size), None) => this.flex_basis(size),
|
||||
_ => this,
|
||||
})
|
||||
.child({
|
||||
canvas(
|
||||
move |bounds, cx| view.update(cx, |r, cx| r.update_size(bounds, cx)),
|
||||
|_, _, _| {},
|
||||
)
|
||||
.absolute()
|
||||
.size_full()
|
||||
})
|
||||
.when_some(self.content_builder.clone(), |this, c| this.child(c(cx)))
|
||||
.when_some(self.content_view.clone(), |this, c| this.child(c))
|
||||
.when_some(self.resize_handle.take(), |this, c| this.child(c))
|
||||
}
|
||||
}
|
||||
|
||||
struct ResizePanelGroupElement {
|
||||
axis: Axis,
|
||||
view: View<ResizablePanelGroup>,
|
||||
}
|
||||
|
||||
impl IntoElement for ResizePanelGroupElement {
|
||||
type Element = Self;
|
||||
|
||||
fn into_element(self) -> Self::Element {
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl Element for ResizePanelGroupElement {
|
||||
type RequestLayoutState = ();
|
||||
type PrepaintState = ();
|
||||
|
||||
fn id(&self) -> Option<gpui::ElementId> {
|
||||
None
|
||||
}
|
||||
|
||||
fn request_layout(
|
||||
&mut self,
|
||||
_: Option<&gpui::GlobalElementId>,
|
||||
cx: &mut WindowContext,
|
||||
) -> (gpui::LayoutId, Self::RequestLayoutState) {
|
||||
(cx.request_layout(Style::default(), None), ())
|
||||
}
|
||||
|
||||
fn prepaint(
|
||||
&mut self,
|
||||
_: Option<&gpui::GlobalElementId>,
|
||||
_: Bounds<Pixels>,
|
||||
_: &mut Self::RequestLayoutState,
|
||||
_: &mut WindowContext,
|
||||
) -> Self::PrepaintState {
|
||||
()
|
||||
}
|
||||
|
||||
fn paint(
|
||||
&mut self,
|
||||
_: Option<&gpui::GlobalElementId>,
|
||||
_: Bounds<Pixels>,
|
||||
_: &mut Self::RequestLayoutState,
|
||||
_: &mut Self::PrepaintState,
|
||||
cx: &mut WindowContext,
|
||||
) {
|
||||
cx.on_mouse_event({
|
||||
let view = self.view.clone();
|
||||
let axis = self.axis;
|
||||
let current_ix = view.read(cx).resizing_panel_ix;
|
||||
move |e: &MouseMoveEvent, phase, cx| {
|
||||
if phase.bubble() {
|
||||
if let Some(ix) = current_ix {
|
||||
view.update(cx, |view, cx| {
|
||||
let panel = view
|
||||
.panels
|
||||
.get(ix)
|
||||
.expect("BUG: invalid panel index")
|
||||
.read(cx);
|
||||
|
||||
match axis {
|
||||
Axis::Horizontal => {
|
||||
view.resize_panels(ix, e.position.x - panel.bounds.left(), cx)
|
||||
}
|
||||
Axis::Vertical => {
|
||||
view.resize_panels(ix, e.position.y - panel.bounds.top(), cx);
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// When any mouse up, stop dragging
|
||||
cx.on_mouse_event({
|
||||
let view = self.view.clone();
|
||||
move |_: &MouseUpEvent, phase, cx| {
|
||||
if phase.bubble() {
|
||||
view.update(cx, |view, cx| view.done_resizing(cx));
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
72
crates/ui/src/resizable/resize_handle.rs
Normal file
72
crates/ui/src/resizable/resize_handle.rs
Normal file
@@ -0,0 +1,72 @@
|
||||
use gpui::{
|
||||
div, prelude::FluentBuilder as _, px, Axis, Div, ElementId, InteractiveElement, IntoElement,
|
||||
ParentElement as _, Pixels, RenderOnce, Stateful, StatefulInteractiveElement, Styled as _,
|
||||
WindowContext,
|
||||
};
|
||||
|
||||
use crate::{theme::ActiveTheme as _, AxisExt as _};
|
||||
|
||||
pub(crate) const HANDLE_PADDING: Pixels = px(4.);
|
||||
pub(crate) const HANDLE_SIZE: Pixels = px(1.);
|
||||
|
||||
#[derive(IntoElement)]
|
||||
pub(crate) struct ResizeHandle {
|
||||
base: Stateful<Div>,
|
||||
axis: Axis,
|
||||
}
|
||||
|
||||
impl ResizeHandle {
|
||||
fn new(id: impl Into<ElementId>, axis: Axis) -> Self {
|
||||
Self {
|
||||
base: div().id(id.into()),
|
||||
axis,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a resize handle for a resizable panel.
|
||||
pub(crate) fn resize_handle(id: impl Into<ElementId>, axis: Axis) -> ResizeHandle {
|
||||
ResizeHandle::new(id, axis)
|
||||
}
|
||||
|
||||
impl InteractiveElement for ResizeHandle {
|
||||
fn interactivity(&mut self) -> &mut gpui::Interactivity {
|
||||
self.base.interactivity()
|
||||
}
|
||||
}
|
||||
impl StatefulInteractiveElement for ResizeHandle {}
|
||||
|
||||
impl RenderOnce for ResizeHandle {
|
||||
fn render(self, cx: &mut WindowContext) -> impl IntoElement {
|
||||
let neg_offset = -HANDLE_PADDING;
|
||||
|
||||
self.base
|
||||
.occlude()
|
||||
.absolute()
|
||||
.flex_shrink_0()
|
||||
.when(self.axis.is_horizontal(), |this| {
|
||||
this.cursor_col_resize()
|
||||
.top_0()
|
||||
.left(neg_offset)
|
||||
.h_full()
|
||||
.w(HANDLE_SIZE)
|
||||
.px(HANDLE_PADDING)
|
||||
})
|
||||
.when(self.axis.is_vertical(), |this| {
|
||||
this.cursor_row_resize()
|
||||
.top(neg_offset)
|
||||
.left_0()
|
||||
.w_full()
|
||||
.h(HANDLE_SIZE)
|
||||
.py(HANDLE_PADDING)
|
||||
})
|
||||
.child(
|
||||
div()
|
||||
.bg(cx.theme().border)
|
||||
.when(self.axis.is_horizontal(), |this| {
|
||||
this.h_full().w(HANDLE_SIZE)
|
||||
})
|
||||
.when(self.axis.is_vertical(), |this| this.w_full().h(HANDLE_SIZE)),
|
||||
)
|
||||
}
|
||||
}
|
||||
355
crates/ui/src/root.rs
Normal file
355
crates/ui/src/root.rs
Normal file
@@ -0,0 +1,355 @@
|
||||
use crate::{
|
||||
drawer::Drawer,
|
||||
modal::Modal,
|
||||
notification::{Notification, NotificationList},
|
||||
theme::ActiveTheme,
|
||||
};
|
||||
use gpui::{
|
||||
div, AnyView, FocusHandle, InteractiveElement, IntoElement, ParentElement as _, Render, Styled,
|
||||
View, ViewContext, VisualContext as _, WindowContext,
|
||||
};
|
||||
use std::{
|
||||
ops::{Deref, DerefMut},
|
||||
rc::Rc,
|
||||
};
|
||||
|
||||
/// Extension trait for [`WindowContext`] and [`ViewContext`] to add drawer functionality.
|
||||
pub trait ContextModal: Sized {
|
||||
/// Opens a Drawer.
|
||||
fn open_drawer<F>(&mut self, build: F)
|
||||
where
|
||||
F: Fn(Drawer, &mut WindowContext) -> Drawer + 'static;
|
||||
|
||||
/// Return true, if there is an active Drawer.
|
||||
fn has_active_drawer(&self) -> bool;
|
||||
|
||||
/// Closes the active Drawer.
|
||||
fn close_drawer(&mut self);
|
||||
|
||||
/// Opens a Modal.
|
||||
fn open_modal<F>(&mut self, build: F)
|
||||
where
|
||||
F: Fn(Modal, &mut WindowContext) -> Modal + 'static;
|
||||
|
||||
/// Return true, if there is an active Modal.
|
||||
fn has_active_modal(&self) -> bool;
|
||||
|
||||
/// Closes the last active Modal.
|
||||
fn close_modal(&mut self);
|
||||
|
||||
/// Closes all active Modals.
|
||||
fn close_all_modals(&mut self);
|
||||
|
||||
/// Pushes a notification to the notification list.
|
||||
fn push_notification(&mut self, note: impl Into<Notification>);
|
||||
fn clear_notifications(&mut self);
|
||||
/// Returns number of notifications.
|
||||
fn notifications(&self) -> Rc<Vec<View<Notification>>>;
|
||||
}
|
||||
|
||||
impl ContextModal for WindowContext<'_> {
|
||||
fn open_drawer<F>(&mut self, build: F)
|
||||
where
|
||||
F: Fn(Drawer, &mut WindowContext) -> Drawer + 'static,
|
||||
{
|
||||
Root::update(self, move |root, cx| {
|
||||
if root.active_drawer.is_none() {
|
||||
root.previous_focus_handle = cx.focused();
|
||||
}
|
||||
|
||||
let focus_handle = cx.focus_handle();
|
||||
focus_handle.focus(cx);
|
||||
|
||||
root.active_drawer = Some(ActiveDrawer {
|
||||
focus_handle,
|
||||
builder: Rc::new(build),
|
||||
});
|
||||
cx.notify();
|
||||
})
|
||||
}
|
||||
|
||||
fn has_active_drawer(&self) -> bool {
|
||||
Root::read(&self).active_drawer.is_some()
|
||||
}
|
||||
|
||||
fn close_drawer(&mut self) {
|
||||
Root::update(self, |root, cx| {
|
||||
root.active_drawer = None;
|
||||
root.focus_back(cx);
|
||||
cx.notify();
|
||||
})
|
||||
}
|
||||
|
||||
fn open_modal<F>(&mut self, build: F)
|
||||
where
|
||||
F: Fn(Modal, &mut WindowContext) -> Modal + 'static,
|
||||
{
|
||||
Root::update(self, move |root, cx| {
|
||||
// Only save focus handle if there are no active modals.
|
||||
// This is used to restore focus when all modals are closed.
|
||||
if root.active_modals.len() == 0 {
|
||||
root.previous_focus_handle = cx.focused();
|
||||
}
|
||||
|
||||
let focus_handle = cx.focus_handle();
|
||||
focus_handle.focus(cx);
|
||||
|
||||
root.active_modals.push(ActiveModal {
|
||||
focus_handle,
|
||||
builder: Rc::new(build),
|
||||
});
|
||||
cx.notify();
|
||||
})
|
||||
}
|
||||
|
||||
fn has_active_modal(&self) -> bool {
|
||||
Root::read(&self).active_modals.len() > 0
|
||||
}
|
||||
|
||||
fn close_modal(&mut self) {
|
||||
Root::update(self, move |root, cx| {
|
||||
root.active_modals.pop();
|
||||
|
||||
if let Some(top_modal) = root.active_modals.last() {
|
||||
// Focus the next modal.
|
||||
top_modal.focus_handle.focus(cx);
|
||||
} else {
|
||||
// Restore focus if there are no more modals.
|
||||
root.focus_back(cx);
|
||||
}
|
||||
cx.notify();
|
||||
})
|
||||
}
|
||||
|
||||
fn close_all_modals(&mut self) {
|
||||
Root::update(self, |root, cx| {
|
||||
root.active_modals.clear();
|
||||
root.focus_back(cx);
|
||||
cx.notify();
|
||||
})
|
||||
}
|
||||
|
||||
fn push_notification(&mut self, note: impl Into<Notification>) {
|
||||
let note = note.into();
|
||||
Root::update(self, move |root, cx| {
|
||||
root.notification.update(cx, |view, cx| view.push(note, cx));
|
||||
cx.notify();
|
||||
})
|
||||
}
|
||||
|
||||
fn clear_notifications(&mut self) {
|
||||
Root::update(self, move |root, cx| {
|
||||
root.notification.update(cx, |view, cx| view.clear(cx));
|
||||
cx.notify();
|
||||
})
|
||||
}
|
||||
|
||||
fn notifications(&self) -> Rc<Vec<View<Notification>>> {
|
||||
Rc::new(Root::read(&self).notification.read(&self).notifications())
|
||||
}
|
||||
}
|
||||
impl<V> ContextModal for ViewContext<'_, V> {
|
||||
fn open_drawer<F>(&mut self, build: F)
|
||||
where
|
||||
F: Fn(Drawer, &mut WindowContext) -> Drawer + 'static,
|
||||
{
|
||||
self.deref_mut().open_drawer(build)
|
||||
}
|
||||
|
||||
fn has_active_modal(&self) -> bool {
|
||||
self.deref().has_active_modal()
|
||||
}
|
||||
|
||||
fn close_drawer(&mut self) {
|
||||
self.deref_mut().close_drawer()
|
||||
}
|
||||
|
||||
fn open_modal<F>(&mut self, build: F)
|
||||
where
|
||||
F: Fn(Modal, &mut WindowContext) -> Modal + 'static,
|
||||
{
|
||||
self.deref_mut().open_modal(build)
|
||||
}
|
||||
|
||||
fn has_active_drawer(&self) -> bool {
|
||||
self.deref().has_active_drawer()
|
||||
}
|
||||
|
||||
/// Close the last active modal.
|
||||
fn close_modal(&mut self) {
|
||||
self.deref_mut().close_modal()
|
||||
}
|
||||
|
||||
/// Close all modals.
|
||||
fn close_all_modals(&mut self) {
|
||||
self.deref_mut().close_all_modals()
|
||||
}
|
||||
|
||||
fn push_notification(&mut self, note: impl Into<Notification>) {
|
||||
self.deref_mut().push_notification(note)
|
||||
}
|
||||
|
||||
fn clear_notifications(&mut self) {
|
||||
self.deref_mut().clear_notifications()
|
||||
}
|
||||
|
||||
fn notifications(&self) -> Rc<Vec<View<Notification>>> {
|
||||
self.deref().notifications()
|
||||
}
|
||||
}
|
||||
|
||||
/// Root is a view for the App window for as the top level view (Must be the first view in the window).
|
||||
///
|
||||
/// It is used to manage the Drawer, Modal, and Notification.
|
||||
pub struct Root {
|
||||
/// Used to store the focus handle of the previous view.
|
||||
/// When the Modal, Drawer closes, we will focus back to the previous view.
|
||||
previous_focus_handle: Option<FocusHandle>,
|
||||
active_drawer: Option<ActiveDrawer>,
|
||||
active_modals: Vec<ActiveModal>,
|
||||
pub notification: View<NotificationList>,
|
||||
view: AnyView,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct ActiveDrawer {
|
||||
focus_handle: FocusHandle,
|
||||
builder: Rc<dyn Fn(Drawer, &mut WindowContext) -> Drawer + 'static>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct ActiveModal {
|
||||
focus_handle: FocusHandle,
|
||||
builder: Rc<dyn Fn(Modal, &mut WindowContext) -> Modal + 'static>,
|
||||
}
|
||||
|
||||
impl Root {
|
||||
pub fn new(view: AnyView, cx: &mut ViewContext<Self>) -> Self {
|
||||
Self {
|
||||
previous_focus_handle: None,
|
||||
active_drawer: None,
|
||||
active_modals: Vec::new(),
|
||||
notification: cx.new_view(NotificationList::new),
|
||||
view,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn update<F>(cx: &mut WindowContext, f: F)
|
||||
where
|
||||
F: FnOnce(&mut Self, &mut ViewContext<Self>) + 'static,
|
||||
{
|
||||
let root = cx
|
||||
.window_handle()
|
||||
.downcast::<Root>()
|
||||
.and_then(|w| w.root_view(cx).ok())
|
||||
.expect("The window root view should be of type `ui::Root`.");
|
||||
|
||||
root.update(cx, |root, cx| f(root, cx))
|
||||
}
|
||||
|
||||
pub fn read<'a>(cx: &'a WindowContext) -> &'a Self {
|
||||
let root = cx
|
||||
.window_handle()
|
||||
.downcast::<Root>()
|
||||
.and_then(|w| w.root_view(cx).ok())
|
||||
.expect("The window root view should be of type `ui::Root`.");
|
||||
|
||||
root.read(cx)
|
||||
}
|
||||
|
||||
fn focus_back(&mut self, cx: &mut WindowContext) {
|
||||
if let Some(handle) = self.previous_focus_handle.clone() {
|
||||
cx.focus(&handle);
|
||||
}
|
||||
}
|
||||
|
||||
// Render Notification layer.
|
||||
pub fn render_notification_layer(cx: &mut WindowContext) -> Option<impl IntoElement> {
|
||||
let root = cx
|
||||
.window_handle()
|
||||
.downcast::<Root>()
|
||||
.and_then(|w| w.root_view(cx).ok())
|
||||
.expect("The window root view should be of type `ui::Root`.");
|
||||
|
||||
Some(div().child(root.read(cx).notification.clone()))
|
||||
}
|
||||
|
||||
/// Render the Drawer layer.
|
||||
pub fn render_drawer_layer(cx: &mut WindowContext) -> Option<impl IntoElement> {
|
||||
let root = cx
|
||||
.window_handle()
|
||||
.downcast::<Root>()
|
||||
.and_then(|w| w.root_view(cx).ok())
|
||||
.expect("The window root view should be of type `ui::Root`.");
|
||||
|
||||
if let Some(active_drawer) = root.read(cx).active_drawer.clone() {
|
||||
let mut drawer = Drawer::new(cx);
|
||||
drawer = (active_drawer.builder)(drawer, cx);
|
||||
drawer.focus_handle = active_drawer.focus_handle.clone();
|
||||
|
||||
return Some(div().child(drawer));
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
/// Render the Modal layer.
|
||||
pub fn render_modal_layer(cx: &mut WindowContext) -> Option<impl IntoElement> {
|
||||
let root = cx
|
||||
.window_handle()
|
||||
.downcast::<Root>()
|
||||
.and_then(|w| w.root_view(cx).ok())
|
||||
.expect("The window root view should be of type `ui::Root`.");
|
||||
|
||||
let active_modals = root.read(cx).active_modals.clone();
|
||||
let mut has_overlay = false;
|
||||
|
||||
if active_modals.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(
|
||||
div().children(active_modals.iter().enumerate().map(|(i, active_modal)| {
|
||||
let mut modal = Modal::new(cx);
|
||||
|
||||
modal = (active_modal.builder)(modal, cx);
|
||||
modal.layer_ix = i;
|
||||
// Give the modal the focus handle, because `modal` is a temporary value, is not possible to
|
||||
// keep the focus handle in the modal.
|
||||
//
|
||||
// So we keep the focus handle in the `active_modal`, this is owned by the `Root`.
|
||||
modal.focus_handle = active_modal.focus_handle.clone();
|
||||
|
||||
// Keep only have one overlay, we only render the first modal with overlay.
|
||||
if has_overlay {
|
||||
modal.overlay_visible = false;
|
||||
}
|
||||
if modal.has_overlay() {
|
||||
has_overlay = true;
|
||||
}
|
||||
|
||||
modal
|
||||
})),
|
||||
)
|
||||
}
|
||||
|
||||
/// Return the root view of the Root.
|
||||
pub fn view(&self) -> &AnyView {
|
||||
&self.view
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for Root {
|
||||
fn render(&mut self, cx: &mut gpui::ViewContext<Self>) -> impl IntoElement {
|
||||
let base_font_size = cx.theme().font_size;
|
||||
cx.set_rem_size(base_font_size);
|
||||
|
||||
div()
|
||||
.id("root")
|
||||
.size_full()
|
||||
.font_family(".SystemUIFont")
|
||||
.bg(cx.theme().background)
|
||||
.text_color(cx.theme().foreground)
|
||||
.child(self.view.clone())
|
||||
}
|
||||
}
|
||||
7
crates/ui/src/scroll/mod.rs
Normal file
7
crates/ui/src/scroll/mod.rs
Normal file
@@ -0,0 +1,7 @@
|
||||
mod scrollable;
|
||||
mod scrollable_mask;
|
||||
mod scrollbar;
|
||||
|
||||
pub use scrollable::*;
|
||||
pub use scrollable_mask::*;
|
||||
pub use scrollbar::*;
|
||||
231
crates/ui/src/scroll/scrollable.rs
Normal file
231
crates/ui/src/scroll/scrollable.rs
Normal file
@@ -0,0 +1,231 @@
|
||||
use std::{cell::Cell, rc::Rc};
|
||||
|
||||
use super::{Scrollbar, ScrollbarAxis, ScrollbarState};
|
||||
use gpui::{
|
||||
canvas, div, relative, AnyElement, Div, Element, ElementId, EntityId, GlobalElementId,
|
||||
InteractiveElement, IntoElement, ParentElement, Pixels, Position, ScrollHandle, SharedString,
|
||||
Size, Stateful, StatefulInteractiveElement, Style, StyleRefinement, Styled, WindowContext,
|
||||
};
|
||||
|
||||
/// A scroll view is a container that allows the user to scroll through a large amount of content.
|
||||
pub struct Scrollable<E> {
|
||||
id: ElementId,
|
||||
element: Option<E>,
|
||||
view_id: EntityId,
|
||||
axis: ScrollbarAxis,
|
||||
/// This is a fake element to handle Styled, InteractiveElement, not used.
|
||||
_element: Stateful<Div>,
|
||||
}
|
||||
|
||||
impl<E> Scrollable<E>
|
||||
where
|
||||
E: Element,
|
||||
{
|
||||
pub(crate) fn new(view_id: EntityId, element: E, axis: ScrollbarAxis) -> Self {
|
||||
let id = ElementId::Name(SharedString::from(format!(
|
||||
"ScrollView:{}-{:?}",
|
||||
view_id,
|
||||
element.id(),
|
||||
)));
|
||||
|
||||
Self {
|
||||
element: Some(element),
|
||||
_element: div().id("fake"),
|
||||
id,
|
||||
view_id,
|
||||
axis,
|
||||
}
|
||||
}
|
||||
|
||||
/// Set only a vertical scrollbar.
|
||||
pub fn vertical(mut self) -> Self {
|
||||
self.set_axis(ScrollbarAxis::Vertical);
|
||||
self
|
||||
}
|
||||
|
||||
/// Set only a horizontal scrollbar.
|
||||
/// In current implementation, this is not supported yet.
|
||||
pub fn horizontal(mut self) -> Self {
|
||||
self.set_axis(ScrollbarAxis::Horizontal);
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the axis of the scroll view.
|
||||
pub fn set_axis(&mut self, axis: ScrollbarAxis) {
|
||||
self.axis = axis;
|
||||
}
|
||||
|
||||
fn with_element_state<R>(
|
||||
&mut self,
|
||||
id: &GlobalElementId,
|
||||
cx: &mut WindowContext,
|
||||
f: impl FnOnce(&mut Self, &mut ScrollViewState, &mut WindowContext) -> R,
|
||||
) -> R {
|
||||
cx.with_optional_element_state::<ScrollViewState, _>(Some(id), |element_state, cx| {
|
||||
let mut element_state = element_state.unwrap().unwrap_or_default();
|
||||
let result = f(self, &mut element_state, cx);
|
||||
(result, Some(element_state))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub struct ScrollViewState {
|
||||
scroll_size: Rc<Cell<Size<Pixels>>>,
|
||||
state: Rc<Cell<ScrollbarState>>,
|
||||
handle: ScrollHandle,
|
||||
}
|
||||
|
||||
impl Default for ScrollViewState {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
handle: ScrollHandle::new(),
|
||||
scroll_size: Rc::new(Cell::new(Size::default())),
|
||||
state: Rc::new(Cell::new(ScrollbarState::default())),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<E> ParentElement for Scrollable<E>
|
||||
where
|
||||
E: Element + ParentElement,
|
||||
{
|
||||
fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
|
||||
if let Some(element) = &mut self.element {
|
||||
element.extend(elements);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<E> Styled for Scrollable<E>
|
||||
where
|
||||
E: Element + Styled,
|
||||
{
|
||||
fn style(&mut self) -> &mut StyleRefinement {
|
||||
if let Some(element) = &mut self.element {
|
||||
element.style()
|
||||
} else {
|
||||
self._element.style()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<E> InteractiveElement for Scrollable<E>
|
||||
where
|
||||
E: Element + InteractiveElement,
|
||||
{
|
||||
fn interactivity(&mut self) -> &mut gpui::Interactivity {
|
||||
if let Some(element) = &mut self.element {
|
||||
element.interactivity()
|
||||
} else {
|
||||
self._element.interactivity()
|
||||
}
|
||||
}
|
||||
}
|
||||
impl<E> StatefulInteractiveElement for Scrollable<E> where E: Element + StatefulInteractiveElement {}
|
||||
|
||||
impl<E> IntoElement for Scrollable<E>
|
||||
where
|
||||
E: Element,
|
||||
{
|
||||
type Element = Self;
|
||||
|
||||
fn into_element(self) -> Self::Element {
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl<E> Element for Scrollable<E>
|
||||
where
|
||||
E: Element,
|
||||
{
|
||||
type RequestLayoutState = AnyElement;
|
||||
type PrepaintState = ScrollViewState;
|
||||
|
||||
fn id(&self) -> Option<gpui::ElementId> {
|
||||
Some(self.id.clone())
|
||||
}
|
||||
|
||||
fn request_layout(
|
||||
&mut self,
|
||||
id: Option<&gpui::GlobalElementId>,
|
||||
cx: &mut gpui::WindowContext,
|
||||
) -> (gpui::LayoutId, Self::RequestLayoutState) {
|
||||
let mut style = Style::default();
|
||||
style.flex_grow = 1.0;
|
||||
style.position = Position::Relative;
|
||||
style.size.width = relative(1.0).into();
|
||||
style.size.height = relative(1.0).into();
|
||||
|
||||
let axis = self.axis;
|
||||
let view_id = self.view_id;
|
||||
|
||||
let scroll_id = self.id.clone();
|
||||
let content = self.element.take().map(|c| c.into_any_element());
|
||||
|
||||
self.with_element_state(id.unwrap(), cx, |_, element_state, cx| {
|
||||
let handle = element_state.handle.clone();
|
||||
let state = element_state.state.clone();
|
||||
let scroll_size = element_state.scroll_size.clone();
|
||||
|
||||
let mut element = div()
|
||||
.relative()
|
||||
.size_full()
|
||||
.overflow_hidden()
|
||||
.child(
|
||||
div()
|
||||
.id(scroll_id)
|
||||
.track_scroll(&handle)
|
||||
.overflow_scroll()
|
||||
.relative()
|
||||
.size_full()
|
||||
.child(div().children(content).child({
|
||||
let scroll_size = element_state.scroll_size.clone();
|
||||
canvas(move |b, _| scroll_size.set(b.size), |_, _, _| {})
|
||||
.absolute()
|
||||
.size_full()
|
||||
})),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.absolute()
|
||||
.top_0()
|
||||
.left_0()
|
||||
.right_0()
|
||||
.bottom_0()
|
||||
.child(
|
||||
Scrollbar::both(view_id, state, handle.clone(), scroll_size.get())
|
||||
.axis(axis),
|
||||
),
|
||||
)
|
||||
.into_any_element();
|
||||
let element_id = element.request_layout(cx);
|
||||
|
||||
let layout_id = cx.request_layout(style, vec![element_id]);
|
||||
|
||||
(layout_id, element)
|
||||
})
|
||||
}
|
||||
|
||||
fn prepaint(
|
||||
&mut self,
|
||||
_: Option<&gpui::GlobalElementId>,
|
||||
_: gpui::Bounds<Pixels>,
|
||||
element: &mut Self::RequestLayoutState,
|
||||
cx: &mut gpui::WindowContext,
|
||||
) -> Self::PrepaintState {
|
||||
element.prepaint(cx);
|
||||
// do nothing
|
||||
ScrollViewState::default()
|
||||
}
|
||||
|
||||
fn paint(
|
||||
&mut self,
|
||||
_: Option<&gpui::GlobalElementId>,
|
||||
_: gpui::Bounds<Pixels>,
|
||||
element: &mut Self::RequestLayoutState,
|
||||
_: &mut Self::PrepaintState,
|
||||
cx: &mut gpui::WindowContext,
|
||||
) {
|
||||
element.paint(cx)
|
||||
}
|
||||
}
|
||||
160
crates/ui/src/scroll/scrollable_mask.rs
Normal file
160
crates/ui/src/scroll/scrollable_mask.rs
Normal file
@@ -0,0 +1,160 @@
|
||||
use gpui::{
|
||||
px, relative, AnyView, Bounds, ContentMask, Corners, Edges, Element, ElementId,
|
||||
GlobalElementId, Hitbox, Hsla, IntoElement, IsZero as _, LayoutId, PaintQuad, Pixels, Point,
|
||||
Position, ScrollHandle, ScrollWheelEvent, Style, WindowContext,
|
||||
};
|
||||
|
||||
/// The scroll axis direction.
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ScrollableAxis {
|
||||
/// Horizontal scroll.
|
||||
Horizontal,
|
||||
/// Vertical scroll.
|
||||
Vertical,
|
||||
}
|
||||
|
||||
/// Make a scrollable mask element to cover the parent view with the mouse wheel event listening.
|
||||
///
|
||||
/// When the mouse wheel is scrolled, will move the `scroll_handle` scrolling with the `axis` direction.
|
||||
/// You can use this `scroll_handle` to control what you want to scroll.
|
||||
/// This is only can handle once axis scrolling.
|
||||
pub struct ScrollableMask {
|
||||
view: AnyView,
|
||||
axis: ScrollableAxis,
|
||||
scroll_handle: ScrollHandle,
|
||||
debug: Option<Hsla>,
|
||||
}
|
||||
|
||||
impl ScrollableMask {
|
||||
/// Create a new scrollable mask element.
|
||||
pub fn new(
|
||||
view: impl Into<AnyView>,
|
||||
axis: ScrollableAxis,
|
||||
scroll_handle: &ScrollHandle,
|
||||
) -> Self {
|
||||
Self {
|
||||
view: view.into(),
|
||||
scroll_handle: scroll_handle.clone(),
|
||||
axis,
|
||||
debug: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Enable the debug border, to show the mask bounds.
|
||||
#[allow(dead_code)]
|
||||
pub fn debug(mut self) -> Self {
|
||||
self.debug = Some(gpui::yellow());
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoElement for ScrollableMask {
|
||||
type Element = Self;
|
||||
|
||||
fn into_element(self) -> Self::Element {
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl Element for ScrollableMask {
|
||||
type RequestLayoutState = ();
|
||||
type PrepaintState = Hitbox;
|
||||
|
||||
fn id(&self) -> Option<ElementId> {
|
||||
None
|
||||
}
|
||||
|
||||
fn request_layout(
|
||||
&mut self,
|
||||
_: Option<&GlobalElementId>,
|
||||
cx: &mut WindowContext,
|
||||
) -> (LayoutId, Self::RequestLayoutState) {
|
||||
let mut style = Style::default();
|
||||
// Set the layout style relative to the table view to get same size.
|
||||
style.position = Position::Absolute;
|
||||
style.flex_grow = 1.0;
|
||||
style.flex_shrink = 1.0;
|
||||
style.size.width = relative(1.).into();
|
||||
style.size.height = relative(1.).into();
|
||||
|
||||
(cx.request_layout(style, None), ())
|
||||
}
|
||||
|
||||
fn prepaint(
|
||||
&mut self,
|
||||
_: Option<&GlobalElementId>,
|
||||
bounds: Bounds<Pixels>,
|
||||
_: &mut Self::RequestLayoutState,
|
||||
cx: &mut WindowContext,
|
||||
) -> Self::PrepaintState {
|
||||
// Move y to bounds height to cover the parent view.
|
||||
let cover_bounds = Bounds {
|
||||
origin: Point {
|
||||
x: bounds.origin.x,
|
||||
y: bounds.origin.y - bounds.size.height,
|
||||
},
|
||||
size: bounds.size,
|
||||
};
|
||||
|
||||
cx.insert_hitbox(cover_bounds, false)
|
||||
}
|
||||
|
||||
fn paint(
|
||||
&mut self,
|
||||
_: Option<&GlobalElementId>,
|
||||
_: Bounds<Pixels>,
|
||||
_: &mut Self::RequestLayoutState,
|
||||
hitbox: &mut Self::PrepaintState,
|
||||
cx: &mut WindowContext,
|
||||
) {
|
||||
let line_height = cx.line_height();
|
||||
let bounds = hitbox.bounds;
|
||||
|
||||
cx.with_content_mask(Some(ContentMask { bounds }), |cx| {
|
||||
if let Some(color) = self.debug {
|
||||
cx.paint_quad(PaintQuad {
|
||||
bounds,
|
||||
border_widths: Edges::all(px(1.0)),
|
||||
border_color: color,
|
||||
background: gpui::transparent_white(),
|
||||
corner_radii: Corners::all(px(0.)),
|
||||
});
|
||||
}
|
||||
|
||||
cx.on_mouse_event({
|
||||
let hitbox = hitbox.clone();
|
||||
let mouse_position = cx.mouse_position();
|
||||
let scroll_handle = self.scroll_handle.clone();
|
||||
let old_offset = scroll_handle.offset();
|
||||
let view_id = self.view.entity_id();
|
||||
let is_horizontal = self.axis == ScrollableAxis::Horizontal;
|
||||
|
||||
move |event: &ScrollWheelEvent, phase, cx| {
|
||||
if bounds.contains(&mouse_position) && phase.bubble() && hitbox.is_hovered(cx) {
|
||||
let delta = event.delta.pixel_delta(line_height);
|
||||
|
||||
if is_horizontal && !delta.x.is_zero() {
|
||||
// When is horizontal scroll, move the horizontal scroll handle to make scrolling.
|
||||
let mut offset = scroll_handle.offset();
|
||||
offset.x += delta.x;
|
||||
scroll_handle.set_offset(offset);
|
||||
}
|
||||
|
||||
if !is_horizontal && !delta.y.is_zero() {
|
||||
// When is vertical scroll, move the vertical scroll handle to make scrolling.
|
||||
let mut offset = scroll_handle.offset();
|
||||
offset.y += delta.y;
|
||||
scroll_handle.set_offset(offset);
|
||||
}
|
||||
|
||||
if old_offset != scroll_handle.offset() {
|
||||
cx.notify(Some(view_id));
|
||||
cx.stop_propagation();
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
630
crates/ui/src/scroll/scrollbar.rs
Normal file
630
crates/ui/src/scroll/scrollbar.rs
Normal file
@@ -0,0 +1,630 @@
|
||||
use std::{cell::Cell, rc::Rc, time::Instant};
|
||||
|
||||
use crate::theme::ActiveTheme;
|
||||
use gpui::{
|
||||
fill, point, px, relative, Bounds, ContentMask, Edges, Element, EntityId, Hitbox, IntoElement,
|
||||
MouseDownEvent, MouseMoveEvent, MouseUpEvent, PaintQuad, Pixels, Point, Position, ScrollHandle,
|
||||
ScrollWheelEvent, Style, UniformListScrollHandle,
|
||||
};
|
||||
|
||||
const MIN_THUMB_SIZE: f32 = 80.;
|
||||
const THUMB_RADIUS: Pixels = Pixels(3.0);
|
||||
const THUMB_INSET: Pixels = Pixels(4.);
|
||||
|
||||
pub trait ScrollHandleOffsetable {
|
||||
fn offset(&self) -> Point<Pixels>;
|
||||
fn set_offset(&self, offset: Point<Pixels>);
|
||||
fn is_uniform_list(&self) -> bool {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
impl ScrollHandleOffsetable for ScrollHandle {
|
||||
fn offset(&self) -> Point<Pixels> {
|
||||
self.offset()
|
||||
}
|
||||
|
||||
fn set_offset(&self, offset: Point<Pixels>) {
|
||||
self.set_offset(offset);
|
||||
}
|
||||
}
|
||||
|
||||
impl ScrollHandleOffsetable for UniformListScrollHandle {
|
||||
fn offset(&self) -> Point<Pixels> {
|
||||
self.0.borrow().base_handle.offset()
|
||||
}
|
||||
|
||||
fn set_offset(&self, offset: Point<Pixels>) {
|
||||
self.0.borrow_mut().base_handle.set_offset(offset)
|
||||
}
|
||||
|
||||
fn is_uniform_list(&self) -> bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct ScrollbarState {
|
||||
hovered_axis: Option<ScrollbarAxis>,
|
||||
hovered_on_thumb: Option<ScrollbarAxis>,
|
||||
dragged_axis: Option<ScrollbarAxis>,
|
||||
drag_pos: Point<Pixels>,
|
||||
last_scroll_offset: Point<Pixels>,
|
||||
last_scroll_time: Option<Instant>,
|
||||
}
|
||||
|
||||
impl Default for ScrollbarState {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
hovered_axis: None,
|
||||
hovered_on_thumb: None,
|
||||
dragged_axis: None,
|
||||
drag_pos: point(px(0.), px(0.)),
|
||||
last_scroll_offset: point(px(0.), px(0.)),
|
||||
last_scroll_time: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ScrollbarState {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
fn with_drag_pos(&self, axis: ScrollbarAxis, pos: Point<Pixels>) -> Self {
|
||||
let mut state = *self;
|
||||
if axis.is_vertical() {
|
||||
state.drag_pos.y = pos.y;
|
||||
} else {
|
||||
state.drag_pos.x = pos.x;
|
||||
}
|
||||
|
||||
state.dragged_axis = Some(axis);
|
||||
state
|
||||
}
|
||||
|
||||
fn with_unset_drag_pos(&self) -> Self {
|
||||
let mut state = *self;
|
||||
state.dragged_axis = None;
|
||||
state
|
||||
}
|
||||
|
||||
fn with_hovered(&self, axis: Option<ScrollbarAxis>) -> Self {
|
||||
let mut state = *self;
|
||||
state.hovered_axis = axis;
|
||||
state
|
||||
}
|
||||
|
||||
fn with_hovered_on_thumb(&self, axis: Option<ScrollbarAxis>) -> Self {
|
||||
let mut state = *self;
|
||||
state.hovered_on_thumb = axis;
|
||||
state
|
||||
}
|
||||
|
||||
fn with_last_scroll(
|
||||
&self,
|
||||
last_scroll_offset: Point<Pixels>,
|
||||
last_scroll_time: Option<Instant>,
|
||||
) -> Self {
|
||||
let mut state = *self;
|
||||
state.last_scroll_offset = last_scroll_offset;
|
||||
state.last_scroll_time = last_scroll_time;
|
||||
state
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ScrollbarAxis {
|
||||
Vertical,
|
||||
Horizontal,
|
||||
Both,
|
||||
}
|
||||
|
||||
impl ScrollbarAxis {
|
||||
#[inline]
|
||||
fn is_vertical(&self) -> bool {
|
||||
matches!(self, Self::Vertical)
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn is_both(&self) -> bool {
|
||||
matches!(self, Self::Both)
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn has_vertical(&self) -> bool {
|
||||
matches!(self, Self::Vertical | Self::Both)
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn has_horizontal(&self) -> bool {
|
||||
matches!(self, Self::Horizontal | Self::Both)
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn all(&self) -> Vec<ScrollbarAxis> {
|
||||
match self {
|
||||
Self::Vertical => vec![Self::Vertical],
|
||||
Self::Horizontal => vec![Self::Horizontal],
|
||||
// This should keep vertical first, vertical is the primary axis
|
||||
// if vertical not need display, then horizontal will not keep right margin.
|
||||
Self::Both => vec![Self::Vertical, Self::Horizontal],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Scrollbar control for scroll-area or a uniform-list.
|
||||
pub struct Scrollbar {
|
||||
view_id: EntityId,
|
||||
axis: ScrollbarAxis,
|
||||
/// When is vertical, this is the height of the scrollbar.
|
||||
width: Pixels,
|
||||
scroll_handle: Rc<Box<dyn ScrollHandleOffsetable>>,
|
||||
scroll_size: gpui::Size<Pixels>,
|
||||
state: Rc<Cell<ScrollbarState>>,
|
||||
}
|
||||
|
||||
impl Scrollbar {
|
||||
fn new(
|
||||
view_id: EntityId,
|
||||
state: Rc<Cell<ScrollbarState>>,
|
||||
axis: ScrollbarAxis,
|
||||
scroll_handle: impl ScrollHandleOffsetable + 'static,
|
||||
scroll_size: gpui::Size<Pixels>,
|
||||
) -> Self {
|
||||
Self {
|
||||
view_id,
|
||||
state,
|
||||
axis,
|
||||
scroll_size,
|
||||
width: px(12.),
|
||||
scroll_handle: Rc::new(Box::new(scroll_handle)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Create with vertical and horizontal scrollbar.
|
||||
pub fn both(
|
||||
view_id: EntityId,
|
||||
state: Rc<Cell<ScrollbarState>>,
|
||||
scroll_handle: impl ScrollHandleOffsetable + 'static,
|
||||
scroll_size: gpui::Size<Pixels>,
|
||||
) -> Self {
|
||||
Self::new(
|
||||
view_id,
|
||||
state,
|
||||
ScrollbarAxis::Both,
|
||||
scroll_handle,
|
||||
scroll_size,
|
||||
)
|
||||
}
|
||||
|
||||
/// Create with horizontal scrollbar.
|
||||
pub fn horizontal(
|
||||
view_id: EntityId,
|
||||
state: Rc<Cell<ScrollbarState>>,
|
||||
scroll_handle: impl ScrollHandleOffsetable + 'static,
|
||||
scroll_size: gpui::Size<Pixels>,
|
||||
) -> Self {
|
||||
Self::new(
|
||||
view_id,
|
||||
state,
|
||||
ScrollbarAxis::Horizontal,
|
||||
scroll_handle,
|
||||
scroll_size,
|
||||
)
|
||||
}
|
||||
|
||||
/// Create with vertical scrollbar.
|
||||
pub fn vertical(
|
||||
view_id: EntityId,
|
||||
state: Rc<Cell<ScrollbarState>>,
|
||||
scroll_handle: impl ScrollHandleOffsetable + 'static,
|
||||
scroll_size: gpui::Size<Pixels>,
|
||||
) -> Self {
|
||||
Self::new(
|
||||
view_id,
|
||||
state,
|
||||
ScrollbarAxis::Vertical,
|
||||
scroll_handle,
|
||||
scroll_size,
|
||||
)
|
||||
}
|
||||
|
||||
/// Create vertical scrollbar for uniform list.
|
||||
pub fn uniform_scroll(
|
||||
view_id: EntityId,
|
||||
state: Rc<Cell<ScrollbarState>>,
|
||||
scroll_handle: UniformListScrollHandle,
|
||||
) -> Self {
|
||||
let scroll_size = scroll_handle
|
||||
.0
|
||||
.borrow()
|
||||
.last_item_size
|
||||
.map(|size| size.contents)
|
||||
.unwrap_or_default();
|
||||
|
||||
Self::new(
|
||||
view_id,
|
||||
state,
|
||||
ScrollbarAxis::Vertical,
|
||||
scroll_handle,
|
||||
scroll_size,
|
||||
)
|
||||
}
|
||||
|
||||
/// Set scrollbar axis.
|
||||
pub fn axis(mut self, axis: ScrollbarAxis) -> Self {
|
||||
self.axis = axis;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoElement for Scrollbar {
|
||||
type Element = Self;
|
||||
|
||||
fn into_element(self) -> Self::Element {
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl Element for Scrollbar {
|
||||
type RequestLayoutState = ();
|
||||
|
||||
type PrepaintState = Hitbox;
|
||||
|
||||
fn id(&self) -> Option<gpui::ElementId> {
|
||||
None
|
||||
}
|
||||
|
||||
fn request_layout(
|
||||
&mut self,
|
||||
_: Option<&gpui::GlobalElementId>,
|
||||
cx: &mut gpui::WindowContext,
|
||||
) -> (gpui::LayoutId, Self::RequestLayoutState) {
|
||||
let mut style = Style::default();
|
||||
style.position = Position::Absolute;
|
||||
style.flex_grow = 1.0;
|
||||
style.flex_shrink = 1.0;
|
||||
style.size.width = relative(1.).into();
|
||||
style.size.height = relative(1.).into();
|
||||
|
||||
(cx.request_layout(style, None), ())
|
||||
}
|
||||
|
||||
fn prepaint(
|
||||
&mut self,
|
||||
_: Option<&gpui::GlobalElementId>,
|
||||
bounds: Bounds<Pixels>,
|
||||
_: &mut Self::RequestLayoutState,
|
||||
cx: &mut gpui::WindowContext,
|
||||
) -> Self::PrepaintState {
|
||||
cx.with_content_mask(Some(ContentMask { bounds }), |cx| {
|
||||
cx.insert_hitbox(bounds, false)
|
||||
})
|
||||
}
|
||||
|
||||
fn paint(
|
||||
&mut self,
|
||||
_: Option<&gpui::GlobalElementId>,
|
||||
_: Bounds<Pixels>,
|
||||
_: &mut Self::RequestLayoutState,
|
||||
hitbox: &mut Self::PrepaintState,
|
||||
cx: &mut gpui::WindowContext,
|
||||
) {
|
||||
let hitbox_bounds = hitbox.bounds;
|
||||
let mut has_both = self.axis.is_both();
|
||||
|
||||
for axis in self.axis.all().into_iter() {
|
||||
const NORMAL_OPACITY: f32 = 0.6;
|
||||
|
||||
let is_vertical = axis.is_vertical();
|
||||
let (scroll_area_size, container_size, scroll_position) = if is_vertical {
|
||||
(
|
||||
self.scroll_size.height,
|
||||
hitbox_bounds.size.height,
|
||||
self.scroll_handle.offset().y,
|
||||
)
|
||||
} else {
|
||||
(
|
||||
self.scroll_size.width,
|
||||
hitbox_bounds.size.width,
|
||||
self.scroll_handle.offset().x,
|
||||
)
|
||||
};
|
||||
|
||||
// The horizontal scrollbar is set avoid overlapping with the vertical scrollbar, if the vertical scrollbar is visible.
|
||||
let margin_end = if has_both && !is_vertical {
|
||||
self.width
|
||||
} else {
|
||||
px(0.)
|
||||
};
|
||||
|
||||
// Hide scrollbar, if the scroll area is smaller than the container.
|
||||
if scroll_area_size <= container_size {
|
||||
has_both = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
let thumb_length =
|
||||
(container_size / scroll_area_size * container_size).max(px(MIN_THUMB_SIZE));
|
||||
let thumb_start = -(scroll_position / (scroll_area_size - container_size)
|
||||
* (container_size - margin_end - thumb_length));
|
||||
let thumb_end = (thumb_start + thumb_length).min(container_size - margin_end);
|
||||
|
||||
let bounds = Bounds {
|
||||
origin: if is_vertical {
|
||||
point(
|
||||
hitbox_bounds.origin.x + hitbox_bounds.size.width - self.width,
|
||||
hitbox_bounds.origin.y,
|
||||
)
|
||||
} else {
|
||||
point(
|
||||
hitbox_bounds.origin.x,
|
||||
hitbox_bounds.origin.y + hitbox_bounds.size.height - self.width,
|
||||
)
|
||||
},
|
||||
size: gpui::Size {
|
||||
width: if is_vertical {
|
||||
self.width
|
||||
} else {
|
||||
hitbox_bounds.size.width
|
||||
},
|
||||
height: if is_vertical {
|
||||
hitbox_bounds.size.height
|
||||
} else {
|
||||
self.width
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
let state = self.state.clone();
|
||||
let (thumb_bg, bar_bg, bar_border, inset, radius) =
|
||||
if state.get().dragged_axis == Some(axis) {
|
||||
(
|
||||
cx.theme().scrollbar_thumb,
|
||||
cx.theme().scrollbar,
|
||||
cx.theme().border,
|
||||
THUMB_INSET - px(1.),
|
||||
THUMB_RADIUS,
|
||||
)
|
||||
} else if state.get().hovered_axis == Some(axis) {
|
||||
if state.get().hovered_on_thumb == Some(axis) {
|
||||
(
|
||||
cx.theme().scrollbar_thumb,
|
||||
cx.theme().scrollbar,
|
||||
cx.theme().border,
|
||||
THUMB_INSET - px(1.),
|
||||
THUMB_RADIUS,
|
||||
)
|
||||
} else {
|
||||
(
|
||||
cx.theme().scrollbar_thumb.opacity(NORMAL_OPACITY),
|
||||
gpui::transparent_black(),
|
||||
gpui::transparent_black(),
|
||||
THUMB_INSET,
|
||||
THUMB_RADIUS,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
let mut idle_state = (
|
||||
gpui::transparent_black(),
|
||||
gpui::transparent_black(),
|
||||
gpui::transparent_black(),
|
||||
THUMB_INSET,
|
||||
THUMB_RADIUS - px(1.),
|
||||
);
|
||||
if let Some(last_time) = state.get().last_scroll_time {
|
||||
let elapsed = Instant::now().duration_since(last_time).as_secs_f32();
|
||||
if elapsed < 1.0 {
|
||||
let y_value = NORMAL_OPACITY - elapsed.powi(10); // y = 1 - x^10
|
||||
idle_state.0 = cx.theme().scrollbar_thumb.opacity(y_value);
|
||||
cx.request_animation_frame();
|
||||
}
|
||||
}
|
||||
idle_state
|
||||
};
|
||||
|
||||
let border_width = px(0.);
|
||||
let thumb_bounds = if is_vertical {
|
||||
Bounds::from_corners(
|
||||
point(
|
||||
bounds.origin.x + inset + border_width,
|
||||
bounds.origin.y + thumb_start + inset,
|
||||
),
|
||||
point(
|
||||
bounds.origin.x + self.width - inset,
|
||||
bounds.origin.y + thumb_end - inset,
|
||||
),
|
||||
)
|
||||
} else {
|
||||
Bounds::from_corners(
|
||||
point(
|
||||
bounds.origin.x + thumb_start + inset,
|
||||
bounds.origin.y + inset + border_width,
|
||||
),
|
||||
point(
|
||||
bounds.origin.x + thumb_end - inset,
|
||||
bounds.origin.y + self.width - inset,
|
||||
),
|
||||
)
|
||||
};
|
||||
|
||||
cx.paint_layer(hitbox_bounds, |cx| {
|
||||
cx.paint_quad(fill(bounds, bar_bg));
|
||||
|
||||
cx.paint_quad(PaintQuad {
|
||||
bounds,
|
||||
corner_radii: (0.).into(),
|
||||
background: gpui::transparent_black(),
|
||||
border_widths: if is_vertical {
|
||||
Edges {
|
||||
top: px(0.),
|
||||
right: px(0.),
|
||||
bottom: px(0.),
|
||||
left: border_width,
|
||||
}
|
||||
} else {
|
||||
Edges {
|
||||
top: border_width,
|
||||
right: px(0.),
|
||||
bottom: px(0.),
|
||||
left: px(0.),
|
||||
}
|
||||
},
|
||||
border_color: bar_border,
|
||||
});
|
||||
|
||||
cx.paint_quad(fill(thumb_bounds, thumb_bg).corner_radii(radius));
|
||||
});
|
||||
|
||||
cx.on_mouse_event({
|
||||
let state = self.state.clone();
|
||||
let view_id = self.view_id;
|
||||
let scroll_handle = self.scroll_handle.clone();
|
||||
|
||||
move |event: &ScrollWheelEvent, phase, cx| {
|
||||
if phase.bubble() && hitbox_bounds.contains(&event.position) {
|
||||
if scroll_handle.offset() != state.get().last_scroll_offset {
|
||||
state.set(
|
||||
state
|
||||
.get()
|
||||
.with_last_scroll(scroll_handle.offset(), Some(Instant::now())),
|
||||
);
|
||||
cx.notify(Some(view_id));
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let safe_range = (-scroll_area_size + container_size)..px(0.);
|
||||
|
||||
cx.on_mouse_event({
|
||||
let state = self.state.clone();
|
||||
let view_id = self.view_id;
|
||||
let scroll_handle = self.scroll_handle.clone();
|
||||
|
||||
move |event: &MouseDownEvent, phase, cx| {
|
||||
if phase.bubble() && bounds.contains(&event.position) {
|
||||
cx.stop_propagation();
|
||||
|
||||
if thumb_bounds.contains(&event.position) {
|
||||
// click on the thumb bar, set the drag position
|
||||
let pos = event.position - thumb_bounds.origin;
|
||||
|
||||
state.set(state.get().with_drag_pos(axis, pos));
|
||||
|
||||
cx.notify(Some(view_id));
|
||||
} else {
|
||||
// click on the scrollbar, jump to the position
|
||||
// Set the thumb bar center to the click position
|
||||
let offset = scroll_handle.offset();
|
||||
let percentage = if is_vertical {
|
||||
(event.position.y - thumb_length / 2. - bounds.origin.y)
|
||||
/ (bounds.size.height - thumb_length)
|
||||
} else {
|
||||
(event.position.x - thumb_length / 2. - bounds.origin.x)
|
||||
/ (bounds.size.width - thumb_length)
|
||||
}
|
||||
.min(1.);
|
||||
|
||||
if is_vertical {
|
||||
scroll_handle.set_offset(point(
|
||||
offset.x,
|
||||
(-scroll_area_size * percentage)
|
||||
.clamp(safe_range.start, safe_range.end),
|
||||
));
|
||||
} else {
|
||||
scroll_handle.set_offset(point(
|
||||
(-scroll_area_size * percentage)
|
||||
.clamp(safe_range.start, safe_range.end),
|
||||
offset.y,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
cx.on_mouse_event({
|
||||
let scroll_handle = self.scroll_handle.clone();
|
||||
let state = self.state.clone();
|
||||
let view_id = self.view_id;
|
||||
|
||||
move |event: &MouseMoveEvent, _, cx| {
|
||||
// Update hovered state for scrollbar
|
||||
if bounds.contains(&event.position) {
|
||||
if state.get().hovered_axis != Some(axis) {
|
||||
state.set(state.get().with_hovered(Some(axis)));
|
||||
cx.notify(Some(view_id));
|
||||
}
|
||||
} else {
|
||||
if state.get().hovered_axis == Some(axis) {
|
||||
if state.get().hovered_axis.is_some() {
|
||||
state.set(state.get().with_hovered(None));
|
||||
cx.notify(Some(view_id));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update hovered state for scrollbar thumb
|
||||
if thumb_bounds.contains(&event.position) {
|
||||
if state.get().hovered_on_thumb != Some(axis) {
|
||||
state.set(state.get().with_hovered_on_thumb(Some(axis)));
|
||||
cx.notify(Some(view_id));
|
||||
}
|
||||
} else {
|
||||
if state.get().hovered_on_thumb == Some(axis) {
|
||||
state.set(state.get().with_hovered_on_thumb(None));
|
||||
cx.notify(Some(view_id));
|
||||
}
|
||||
}
|
||||
|
||||
// Move thumb position on dragging
|
||||
if state.get().dragged_axis == Some(axis) && event.dragging() {
|
||||
// drag_pos is the position of the mouse down event
|
||||
// We need to keep the thumb bar still at the origin down position
|
||||
let drag_pos = state.get().drag_pos;
|
||||
|
||||
let percentage = (if is_vertical {
|
||||
(event.position.y - drag_pos.y - bounds.origin.y)
|
||||
/ (bounds.size.height - thumb_length)
|
||||
} else {
|
||||
(event.position.x - drag_pos.x - bounds.origin.x)
|
||||
/ (bounds.size.width - thumb_length - margin_end)
|
||||
})
|
||||
.clamp(0., 1.);
|
||||
|
||||
let offset = if is_vertical {
|
||||
point(
|
||||
scroll_handle.offset().x,
|
||||
(-(scroll_area_size - container_size) * percentage)
|
||||
.clamp(safe_range.start, safe_range.end),
|
||||
)
|
||||
} else {
|
||||
point(
|
||||
(-(scroll_area_size - container_size) * percentage)
|
||||
.clamp(safe_range.start, safe_range.end),
|
||||
scroll_handle.offset().y,
|
||||
)
|
||||
};
|
||||
|
||||
scroll_handle.set_offset(offset);
|
||||
cx.notify(Some(view_id));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
cx.on_mouse_event({
|
||||
let view_id = self.view_id;
|
||||
let state = self.state.clone();
|
||||
|
||||
move |_event: &MouseUpEvent, phase, cx| {
|
||||
if phase.bubble() {
|
||||
state.set(state.get().with_unset_drag_pos());
|
||||
cx.notify(Some(view_id));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
77
crates/ui/src/sidebar/footer.rs
Normal file
77
crates/ui/src/sidebar/footer.rs
Normal file
@@ -0,0 +1,77 @@
|
||||
use gpui::{
|
||||
prelude::FluentBuilder as _, Div, ElementId, InteractiveElement, IntoElement, ParentElement,
|
||||
RenderOnce, SharedString, Styled,
|
||||
};
|
||||
|
||||
use crate::{h_flex, popup_menu::PopupMenuExt, theme::ActiveTheme as _, Collapsible, Selectable};
|
||||
|
||||
#[derive(IntoElement)]
|
||||
pub struct SidebarFooter {
|
||||
id: ElementId,
|
||||
base: Div,
|
||||
selected: bool,
|
||||
is_collapsed: bool,
|
||||
}
|
||||
|
||||
impl SidebarFooter {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
id: SharedString::from("sidebar-footer").into(),
|
||||
base: h_flex().gap_2().w_full(),
|
||||
selected: false,
|
||||
is_collapsed: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
impl Selectable for SidebarFooter {
|
||||
fn selected(mut self, selected: bool) -> Self {
|
||||
self.selected = selected;
|
||||
self
|
||||
}
|
||||
|
||||
fn element_id(&self) -> &gpui::ElementId {
|
||||
&self.id
|
||||
}
|
||||
}
|
||||
impl Collapsible for SidebarFooter {
|
||||
fn is_collapsed(&self) -> bool {
|
||||
self.is_collapsed
|
||||
}
|
||||
|
||||
fn collapsed(mut self, collapsed: bool) -> Self {
|
||||
self.is_collapsed = collapsed;
|
||||
self
|
||||
}
|
||||
}
|
||||
impl ParentElement for SidebarFooter {
|
||||
fn extend(&mut self, elements: impl IntoIterator<Item = gpui::AnyElement>) {
|
||||
self.base.extend(elements);
|
||||
}
|
||||
}
|
||||
impl Styled for SidebarFooter {
|
||||
fn style(&mut self) -> &mut gpui::StyleRefinement {
|
||||
self.base.style()
|
||||
}
|
||||
}
|
||||
impl PopupMenuExt for SidebarFooter {}
|
||||
impl RenderOnce for SidebarFooter {
|
||||
fn render(self, cx: &mut gpui::WindowContext) -> impl gpui::IntoElement {
|
||||
h_flex()
|
||||
.id(self.id)
|
||||
.gap_2()
|
||||
.p_2()
|
||||
.w_full()
|
||||
.justify_between()
|
||||
.cursor_pointer()
|
||||
.rounded_md()
|
||||
.hover(|this| {
|
||||
this.bg(cx.theme().sidebar_accent)
|
||||
.text_color(cx.theme().sidebar_accent_foreground)
|
||||
})
|
||||
.when(self.selected, |this| {
|
||||
this.bg(cx.theme().sidebar_accent)
|
||||
.text_color(cx.theme().sidebar_accent_foreground)
|
||||
})
|
||||
.child(self.base)
|
||||
}
|
||||
}
|
||||
71
crates/ui/src/sidebar/group.rs
Normal file
71
crates/ui/src/sidebar/group.rs
Normal file
@@ -0,0 +1,71 @@
|
||||
use crate::{theme::ActiveTheme, v_flex, Collapsible};
|
||||
use gpui::{
|
||||
div, prelude::FluentBuilder as _, Div, IntoElement, ParentElement, RenderOnce, SharedString,
|
||||
Styled as _, WindowContext,
|
||||
};
|
||||
|
||||
/// A sidebar group
|
||||
#[derive(IntoElement)]
|
||||
pub struct SidebarGroup<E: Collapsible + IntoElement + 'static> {
|
||||
base: Div,
|
||||
label: SharedString,
|
||||
is_collapsed: bool,
|
||||
children: Vec<E>,
|
||||
}
|
||||
|
||||
impl<E: Collapsible + IntoElement> SidebarGroup<E> {
|
||||
pub fn new(label: impl Into<SharedString>) -> Self {
|
||||
Self {
|
||||
base: div().gap_2().flex_col(),
|
||||
label: label.into(),
|
||||
is_collapsed: false,
|
||||
children: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn child(mut self, child: E) -> Self {
|
||||
self.children.push(child);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn children(mut self, children: impl IntoIterator<Item = E>) -> Self {
|
||||
self.children.extend(children);
|
||||
self
|
||||
}
|
||||
}
|
||||
impl<E: Collapsible + IntoElement> Collapsible for SidebarGroup<E> {
|
||||
fn is_collapsed(&self) -> bool {
|
||||
self.is_collapsed
|
||||
}
|
||||
|
||||
fn collapsed(mut self, collapsed: bool) -> Self {
|
||||
self.is_collapsed = collapsed;
|
||||
self
|
||||
}
|
||||
}
|
||||
impl<E: Collapsible + IntoElement> RenderOnce for SidebarGroup<E> {
|
||||
fn render(self, cx: &mut WindowContext) -> impl IntoElement {
|
||||
v_flex()
|
||||
.relative()
|
||||
.p_2()
|
||||
.when(!self.is_collapsed, |this| {
|
||||
this.child(
|
||||
div()
|
||||
.flex_shrink_0()
|
||||
.px_2()
|
||||
.rounded_md()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().sidebar_foreground.opacity(0.7))
|
||||
.h_8()
|
||||
.child(self.label),
|
||||
)
|
||||
})
|
||||
.child(
|
||||
self.base.children(
|
||||
self.children
|
||||
.into_iter()
|
||||
.map(|child| child.collapsed(self.is_collapsed)),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
77
crates/ui/src/sidebar/header.rs
Normal file
77
crates/ui/src/sidebar/header.rs
Normal file
@@ -0,0 +1,77 @@
|
||||
use gpui::{
|
||||
prelude::FluentBuilder as _, Div, ElementId, InteractiveElement, IntoElement, ParentElement,
|
||||
RenderOnce, SharedString, Styled,
|
||||
};
|
||||
|
||||
use crate::{h_flex, popup_menu::PopupMenuExt, theme::ActiveTheme as _, Collapsible, Selectable};
|
||||
|
||||
#[derive(IntoElement)]
|
||||
pub struct SidebarHeader {
|
||||
id: ElementId,
|
||||
base: Div,
|
||||
selected: bool,
|
||||
is_collapsed: bool,
|
||||
}
|
||||
|
||||
impl SidebarHeader {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
id: SharedString::from("sidebar-header").into(),
|
||||
base: h_flex().gap_2().w_full(),
|
||||
selected: false,
|
||||
is_collapsed: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
impl Selectable for SidebarHeader {
|
||||
fn selected(mut self, selected: bool) -> Self {
|
||||
self.selected = selected;
|
||||
self
|
||||
}
|
||||
|
||||
fn element_id(&self) -> &gpui::ElementId {
|
||||
&self.id
|
||||
}
|
||||
}
|
||||
impl Collapsible for SidebarHeader {
|
||||
fn is_collapsed(&self) -> bool {
|
||||
self.is_collapsed
|
||||
}
|
||||
|
||||
fn collapsed(mut self, collapsed: bool) -> Self {
|
||||
self.is_collapsed = collapsed;
|
||||
self
|
||||
}
|
||||
}
|
||||
impl ParentElement for SidebarHeader {
|
||||
fn extend(&mut self, elements: impl IntoIterator<Item = gpui::AnyElement>) {
|
||||
self.base.extend(elements);
|
||||
}
|
||||
}
|
||||
impl Styled for SidebarHeader {
|
||||
fn style(&mut self) -> &mut gpui::StyleRefinement {
|
||||
self.base.style()
|
||||
}
|
||||
}
|
||||
impl PopupMenuExt for SidebarHeader {}
|
||||
impl RenderOnce for SidebarHeader {
|
||||
fn render(self, cx: &mut gpui::WindowContext) -> impl gpui::IntoElement {
|
||||
h_flex()
|
||||
.id(self.id)
|
||||
.gap_2()
|
||||
.p_2()
|
||||
.w_full()
|
||||
.justify_between()
|
||||
.cursor_pointer()
|
||||
.rounded_md()
|
||||
.hover(|this| {
|
||||
this.bg(cx.theme().sidebar_accent)
|
||||
.text_color(cx.theme().sidebar_accent_foreground)
|
||||
})
|
||||
.when(self.selected, |this| {
|
||||
this.bg(cx.theme().sidebar_accent)
|
||||
.text_color(cx.theme().sidebar_accent_foreground)
|
||||
})
|
||||
.child(self.base)
|
||||
}
|
||||
}
|
||||
237
crates/ui/src/sidebar/menu.rs
Normal file
237
crates/ui/src/sidebar/menu.rs
Normal file
@@ -0,0 +1,237 @@
|
||||
use crate::{h_flex, theme::ActiveTheme as _, v_flex, Collapsible, Icon, IconName, StyledExt};
|
||||
use gpui::{
|
||||
div, percentage, prelude::FluentBuilder as _, ClickEvent, InteractiveElement as _, IntoElement,
|
||||
ParentElement as _, RenderOnce, SharedString, StatefulInteractiveElement as _, Styled as _,
|
||||
WindowContext,
|
||||
};
|
||||
use std::rc::Rc;
|
||||
|
||||
#[derive(IntoElement)]
|
||||
pub struct SidebarMenu {
|
||||
is_collapsed: bool,
|
||||
items: Vec<SidebarMenuItem>,
|
||||
}
|
||||
|
||||
impl SidebarMenu {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
items: Vec::new(),
|
||||
is_collapsed: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn menu(
|
||||
mut self,
|
||||
label: impl Into<SharedString>,
|
||||
icon: Option<Icon>,
|
||||
active: bool,
|
||||
handler: impl Fn(&ClickEvent, &mut WindowContext) + 'static,
|
||||
) -> Self {
|
||||
self.items.push(SidebarMenuItem::Item {
|
||||
icon,
|
||||
label: label.into(),
|
||||
handler: Rc::new(handler),
|
||||
active,
|
||||
is_collapsed: self.is_collapsed,
|
||||
});
|
||||
self
|
||||
}
|
||||
|
||||
pub fn submenu(
|
||||
mut self,
|
||||
label: impl Into<SharedString>,
|
||||
icon: Option<Icon>,
|
||||
open: bool,
|
||||
items: impl FnOnce(SidebarMenu) -> Self,
|
||||
handler: impl Fn(&ClickEvent, &mut WindowContext) + 'static,
|
||||
) -> Self {
|
||||
let menu = SidebarMenu::new();
|
||||
let menu = items(menu);
|
||||
self.items.push(SidebarMenuItem::Submenu {
|
||||
icon,
|
||||
label: label.into(),
|
||||
items: menu.items,
|
||||
is_open: open,
|
||||
is_collapsed: self.is_collapsed,
|
||||
handler: Rc::new(handler),
|
||||
});
|
||||
self
|
||||
}
|
||||
}
|
||||
impl Collapsible for SidebarMenu {
|
||||
fn is_collapsed(&self) -> bool {
|
||||
self.is_collapsed
|
||||
}
|
||||
|
||||
fn collapsed(mut self, collapsed: bool) -> Self {
|
||||
self.is_collapsed = collapsed;
|
||||
self
|
||||
}
|
||||
}
|
||||
impl RenderOnce for SidebarMenu {
|
||||
fn render(self, _: &mut WindowContext) -> impl IntoElement {
|
||||
v_flex()
|
||||
.gap_2()
|
||||
.children(self.items.into_iter().map(|mut item| {
|
||||
match &mut item {
|
||||
SidebarMenuItem::Item { is_collapsed, .. } => *is_collapsed = self.is_collapsed,
|
||||
SidebarMenuItem::Submenu { is_collapsed, .. } => {
|
||||
*is_collapsed = self.is_collapsed
|
||||
}
|
||||
}
|
||||
item
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
/// A sidebar menu item
|
||||
#[derive(IntoElement)]
|
||||
enum SidebarMenuItem {
|
||||
Item {
|
||||
icon: Option<Icon>,
|
||||
label: SharedString,
|
||||
handler: Rc<dyn Fn(&ClickEvent, &mut WindowContext)>,
|
||||
active: bool,
|
||||
is_collapsed: bool,
|
||||
},
|
||||
Submenu {
|
||||
icon: Option<Icon>,
|
||||
label: SharedString,
|
||||
handler: Rc<dyn Fn(&ClickEvent, &mut WindowContext)>,
|
||||
items: Vec<SidebarMenuItem>,
|
||||
is_open: bool,
|
||||
is_collapsed: bool,
|
||||
},
|
||||
}
|
||||
|
||||
impl SidebarMenuItem {
|
||||
fn is_submenu(&self) -> bool {
|
||||
matches!(self, SidebarMenuItem::Submenu { .. })
|
||||
}
|
||||
|
||||
fn icon(&self) -> Option<Icon> {
|
||||
match self {
|
||||
SidebarMenuItem::Item { icon, .. } => icon.clone(),
|
||||
SidebarMenuItem::Submenu { icon, .. } => icon.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn label(&self) -> SharedString {
|
||||
match self {
|
||||
SidebarMenuItem::Item { label, .. } => label.clone(),
|
||||
SidebarMenuItem::Submenu { label, .. } => label.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn is_active(&self) -> bool {
|
||||
match self {
|
||||
SidebarMenuItem::Item { active, .. } => *active,
|
||||
SidebarMenuItem::Submenu { .. } => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn is_open(&self) -> bool {
|
||||
match self {
|
||||
SidebarMenuItem::Item { .. } => false,
|
||||
SidebarMenuItem::Submenu { is_open, items, .. } => {
|
||||
*is_open || items.iter().any(|item| item.is_active())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn is_collapsed(&self) -> bool {
|
||||
match self {
|
||||
SidebarMenuItem::Item { is_collapsed, .. } => *is_collapsed,
|
||||
SidebarMenuItem::Submenu { is_collapsed, .. } => *is_collapsed,
|
||||
}
|
||||
}
|
||||
|
||||
fn render_menu_item(
|
||||
&self,
|
||||
is_submenu: bool,
|
||||
is_active: bool,
|
||||
is_open: bool,
|
||||
cx: &WindowContext,
|
||||
) -> impl IntoElement {
|
||||
let handler = match &self {
|
||||
SidebarMenuItem::Item { handler, .. } => Some(handler.clone()),
|
||||
SidebarMenuItem::Submenu { handler, .. } => Some(handler.clone()),
|
||||
};
|
||||
let is_collapsed = self.is_collapsed();
|
||||
|
||||
h_flex()
|
||||
.id("sidebar-menu-item")
|
||||
.overflow_hidden()
|
||||
.flex_shrink_0()
|
||||
.p_2()
|
||||
.gap_2()
|
||||
.items_center()
|
||||
.rounded_md()
|
||||
.text_sm()
|
||||
.cursor_pointer()
|
||||
.hover(|this| {
|
||||
this.bg(cx.theme().sidebar_accent)
|
||||
.text_color(cx.theme().sidebar_accent_foreground)
|
||||
})
|
||||
.when(is_active, |this| {
|
||||
this.font_medium()
|
||||
.bg(cx.theme().sidebar_accent)
|
||||
.text_color(cx.theme().sidebar_accent_foreground)
|
||||
})
|
||||
.when_some(self.icon(), |this, icon| this.child(icon.size_4()))
|
||||
.when(is_collapsed, |this| {
|
||||
this.justify_center().size_7().mx_auto()
|
||||
})
|
||||
.when(!is_collapsed, |this| {
|
||||
this.h_7()
|
||||
.child(div().flex_1().child(self.label()))
|
||||
.when(is_submenu, |this| {
|
||||
this.child(
|
||||
Icon::new(IconName::ChevronRight)
|
||||
.size_4()
|
||||
.when(is_open, |this| this.rotate(percentage(90. / 360.))),
|
||||
)
|
||||
})
|
||||
})
|
||||
.when_some(handler, |this, handler| {
|
||||
this.on_click(move |ev, cx| handler(ev, cx))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for SidebarMenuItem {
|
||||
fn render(self, cx: &mut WindowContext) -> impl IntoElement {
|
||||
let is_submenu = self.is_submenu();
|
||||
let is_active = self.is_active();
|
||||
let is_open = self.is_open();
|
||||
|
||||
div()
|
||||
.w_full()
|
||||
.child(self.render_menu_item(is_submenu, is_active, is_open, cx))
|
||||
.when(is_open, |this| {
|
||||
this.map(|this| match self {
|
||||
SidebarMenuItem::Submenu {
|
||||
items,
|
||||
is_collapsed,
|
||||
..
|
||||
} => {
|
||||
if is_collapsed {
|
||||
this
|
||||
} else {
|
||||
this.child(
|
||||
v_flex()
|
||||
.border_l_1()
|
||||
.border_color(cx.theme().sidebar_border)
|
||||
.gap_1()
|
||||
.mx_3p5()
|
||||
.px_2p5()
|
||||
.py_0p5()
|
||||
.children(items),
|
||||
)
|
||||
}
|
||||
}
|
||||
_ => this,
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
212
crates/ui/src/sidebar/mod.rs
Normal file
212
crates/ui/src/sidebar/mod.rs
Normal file
@@ -0,0 +1,212 @@
|
||||
use crate::{
|
||||
button::{Button, ButtonVariants},
|
||||
h_flex,
|
||||
scroll::ScrollbarAxis,
|
||||
theme::ActiveTheme,
|
||||
v_flex, Collapsible, Icon, IconName, Side, Sizable, StyledExt,
|
||||
};
|
||||
use gpui::{
|
||||
div, prelude::FluentBuilder, px, AnyElement, ClickEvent, Entity, EntityId,
|
||||
InteractiveElement as _, IntoElement, ParentElement, Pixels, Render, RenderOnce, Styled, View,
|
||||
WindowContext,
|
||||
};
|
||||
use std::rc::Rc;
|
||||
|
||||
mod footer;
|
||||
mod group;
|
||||
mod header;
|
||||
mod menu;
|
||||
pub use footer::*;
|
||||
pub use group::*;
|
||||
pub use header::*;
|
||||
pub use menu::*;
|
||||
|
||||
const DEFAULT_WIDTH: Pixels = px(255.);
|
||||
const COLLAPSED_WIDTH: Pixels = px(48.);
|
||||
|
||||
/// A sidebar
|
||||
#[derive(IntoElement)]
|
||||
pub struct Sidebar<E: Collapsible + IntoElement + 'static> {
|
||||
/// The parent view id
|
||||
view_id: EntityId,
|
||||
content: Vec<E>,
|
||||
/// header view
|
||||
header: Option<AnyElement>,
|
||||
/// footer view
|
||||
footer: Option<AnyElement>,
|
||||
/// The side of the sidebar
|
||||
side: Side,
|
||||
collapsible: bool,
|
||||
width: Pixels,
|
||||
is_collapsed: bool,
|
||||
}
|
||||
|
||||
impl<E: Collapsible + IntoElement> Sidebar<E> {
|
||||
fn new(view_id: EntityId, side: Side) -> Self {
|
||||
Self {
|
||||
view_id,
|
||||
content: vec![],
|
||||
header: None,
|
||||
footer: None,
|
||||
side,
|
||||
collapsible: true,
|
||||
width: DEFAULT_WIDTH,
|
||||
is_collapsed: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn left<V: Render + 'static>(view: &View<V>) -> Self {
|
||||
Self::new(view.entity_id(), Side::Left)
|
||||
}
|
||||
|
||||
pub fn right<V: Render + 'static>(view: &View<V>) -> Self {
|
||||
Self::new(view.entity_id(), Side::Right)
|
||||
}
|
||||
|
||||
/// Set the width of the sidebar
|
||||
pub fn width(mut self, width: Pixels) -> Self {
|
||||
self.width = width;
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the sidebar to be collapsible, default is true
|
||||
pub fn collapsible(mut self, collapsible: bool) -> Self {
|
||||
self.collapsible = collapsible;
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the sidebar to be collapsed
|
||||
pub fn collapsed(mut self, collapsed: bool) -> Self {
|
||||
self.is_collapsed = collapsed;
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the header of the sidebar.
|
||||
pub fn header(mut self, header: impl IntoElement) -> Self {
|
||||
self.header = Some(header.into_any_element());
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the footer of the sidebar.
|
||||
pub fn footer(mut self, footer: impl IntoElement) -> Self {
|
||||
self.footer = Some(footer.into_any_element());
|
||||
self
|
||||
}
|
||||
|
||||
/// Add a child element to the sidebar, the child must implement `Collapsible`
|
||||
pub fn child(mut self, child: E) -> Self {
|
||||
self.content.push(child);
|
||||
self
|
||||
}
|
||||
|
||||
/// Add multiple children to the sidebar, the children must implement `Collapsible`
|
||||
pub fn children(mut self, children: impl IntoIterator<Item = E>) -> Self {
|
||||
self.content.extend(children);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// Sidebar collapse button with Icon.
|
||||
#[derive(IntoElement)]
|
||||
pub struct SidebarToggleButton {
|
||||
btn: Button,
|
||||
is_collapsed: bool,
|
||||
side: Side,
|
||||
on_click: Option<Rc<dyn Fn(&ClickEvent, &mut WindowContext)>>,
|
||||
}
|
||||
|
||||
impl SidebarToggleButton {
|
||||
fn new(side: Side) -> Self {
|
||||
Self {
|
||||
btn: Button::new("sidebar-collapse").ghost().small(),
|
||||
is_collapsed: false,
|
||||
side,
|
||||
on_click: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn left() -> Self {
|
||||
Self::new(Side::Left)
|
||||
}
|
||||
|
||||
pub fn right() -> Self {
|
||||
Self::new(Side::Right)
|
||||
}
|
||||
|
||||
pub fn collapsed(mut self, is_collapsed: bool) -> Self {
|
||||
self.is_collapsed = is_collapsed;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn on_click(
|
||||
mut self,
|
||||
on_click: impl Fn(&ClickEvent, &mut WindowContext) + 'static,
|
||||
) -> Self {
|
||||
self.on_click = Some(Rc::new(on_click));
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for SidebarToggleButton {
|
||||
fn render(self, _: &mut WindowContext) -> impl IntoElement {
|
||||
let is_collapsed = self.is_collapsed;
|
||||
let on_click = self.on_click.clone();
|
||||
|
||||
let icon = if is_collapsed {
|
||||
if self.side.is_left() {
|
||||
IconName::PanelLeftOpen
|
||||
} else {
|
||||
IconName::PanelRightOpen
|
||||
}
|
||||
} else {
|
||||
if self.side.is_left() {
|
||||
IconName::PanelLeftClose
|
||||
} else {
|
||||
IconName::PanelRightClose
|
||||
}
|
||||
};
|
||||
|
||||
self.btn
|
||||
.when_some(on_click, |this, on_click| {
|
||||
this.on_click(move |ev, cx| {
|
||||
on_click(ev, cx);
|
||||
})
|
||||
})
|
||||
.icon(Icon::new(icon).size_4())
|
||||
}
|
||||
}
|
||||
|
||||
impl<E: Collapsible + IntoElement> RenderOnce for Sidebar<E> {
|
||||
fn render(mut self, cx: &mut WindowContext) -> impl IntoElement {
|
||||
let is_collaped = self.is_collapsed;
|
||||
v_flex()
|
||||
.id("sidebar")
|
||||
.w(self.width)
|
||||
.when(self.is_collapsed, |this| this.w(COLLAPSED_WIDTH))
|
||||
.flex_shrink_0()
|
||||
.h_full()
|
||||
.overflow_hidden()
|
||||
.relative()
|
||||
.bg(cx.theme().sidebar)
|
||||
.text_color(cx.theme().sidebar_foreground)
|
||||
.border_color(cx.theme().sidebar_border)
|
||||
.map(|this| match self.side {
|
||||
Side::Left => this.border_r_1(),
|
||||
Side::Right => this.text_2xl(),
|
||||
})
|
||||
.when_some(self.header.take(), |this, header| {
|
||||
this.child(h_flex().id("header").p_2().gap_2().child(header))
|
||||
})
|
||||
.child(
|
||||
v_flex().id("content").flex_1().min_h_0().child(
|
||||
div()
|
||||
.children(self.content.into_iter().map(|c| c.collapsed(is_collaped)))
|
||||
.gap_2()
|
||||
.scrollable(self.view_id, ScrollbarAxis::Vertical),
|
||||
),
|
||||
)
|
||||
.when_some(self.footer.take(), |this, footer| {
|
||||
this.child(h_flex().id("footer").gap_2().p_2().child(footer))
|
||||
})
|
||||
}
|
||||
}
|
||||
42
crates/ui/src/skeleton.rs
Normal file
42
crates/ui/src/skeleton.rs
Normal file
@@ -0,0 +1,42 @@
|
||||
use crate::theme::ActiveTheme;
|
||||
use gpui::{
|
||||
bounce, div, ease_in_out, Animation, AnimationExt, Div, IntoElement, ParentElement as _,
|
||||
RenderOnce, Styled,
|
||||
};
|
||||
use std::time::Duration;
|
||||
|
||||
#[derive(IntoElement)]
|
||||
pub struct Skeleton {
|
||||
base: Div,
|
||||
}
|
||||
|
||||
impl Skeleton {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
base: div().w_full().h_4().rounded_md(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Styled for Skeleton {
|
||||
fn style(&mut self) -> &mut gpui::StyleRefinement {
|
||||
self.base.style()
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for Skeleton {
|
||||
fn render(self, cx: &mut gpui::WindowContext) -> impl IntoElement {
|
||||
div().child(
|
||||
self.base.bg(cx.theme().skeleton).with_animation(
|
||||
"skeleton",
|
||||
Animation::new(Duration::from_secs(2))
|
||||
.repeat()
|
||||
.with_easing(bounce(ease_in_out)),
|
||||
move |this, delta| {
|
||||
let v = 1.0 - delta * 0.5;
|
||||
this.opacity(v)
|
||||
},
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
196
crates/ui/src/slider.rs
Normal file
196
crates/ui/src/slider.rs
Normal file
@@ -0,0 +1,196 @@
|
||||
use crate::{theme::ActiveTheme, tooltip::Tooltip};
|
||||
use gpui::{
|
||||
canvas, div, prelude::FluentBuilder as _, px, relative, Axis, Bounds, DragMoveEvent, EntityId,
|
||||
EventEmitter, InteractiveElement, IntoElement, MouseButton, MouseDownEvent, ParentElement as _,
|
||||
Pixels, Point, Render, StatefulInteractiveElement as _, Styled, ViewContext,
|
||||
VisualContext as _,
|
||||
};
|
||||
|
||||
#[derive(Clone, Render)]
|
||||
pub struct DragThumb(EntityId);
|
||||
|
||||
pub enum SliderEvent {
|
||||
Change(f32),
|
||||
}
|
||||
|
||||
/// A Slider element.
|
||||
pub struct Slider {
|
||||
axis: Axis,
|
||||
min: f32,
|
||||
max: f32,
|
||||
step: f32,
|
||||
value: f32,
|
||||
bounds: Bounds<Pixels>,
|
||||
}
|
||||
|
||||
impl Slider {
|
||||
fn new(axis: Axis) -> Self {
|
||||
Self {
|
||||
axis,
|
||||
min: 0.0,
|
||||
max: 100.0,
|
||||
step: 1.0,
|
||||
value: 0.0,
|
||||
bounds: Bounds::default(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn horizontal() -> Self {
|
||||
Self::new(Axis::Horizontal)
|
||||
}
|
||||
|
||||
/// Set the minimum value of the slider, default: 0.0
|
||||
pub fn min(mut self, min: f32) -> Self {
|
||||
self.min = min;
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the maximum value of the slider, default: 100.0
|
||||
pub fn max(mut self, max: f32) -> Self {
|
||||
self.max = max;
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the step value of the slider, default: 1.0
|
||||
pub fn step(mut self, step: f32) -> Self {
|
||||
self.step = step;
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the default value of the slider, default: 0.0
|
||||
pub fn default_value(mut self, value: f32) -> Self {
|
||||
self.value = value;
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the value of the slider.
|
||||
pub fn set_value(&mut self, value: f32, cx: &mut gpui::ViewContext<Self>) {
|
||||
self.value = value;
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Return percentage value of the slider, range of 0.0..1.0
|
||||
fn relative_value(&self) -> f32 {
|
||||
let step = self.step;
|
||||
let value = self.value;
|
||||
let min = self.min;
|
||||
let max = self.max;
|
||||
|
||||
let relative_value = (value - min) / (max - min);
|
||||
let relative_step = step / (max - min);
|
||||
|
||||
let relative_value = (relative_value / relative_step).round() * relative_step;
|
||||
relative_value.clamp(0.0, 1.0)
|
||||
}
|
||||
|
||||
/// Update value by mouse position
|
||||
fn update_value_by_position(
|
||||
&mut self,
|
||||
position: Point<Pixels>,
|
||||
cx: &mut gpui::ViewContext<Self>,
|
||||
) {
|
||||
let bounds = self.bounds;
|
||||
let axis = self.axis;
|
||||
let min = self.min;
|
||||
let max = self.max;
|
||||
let step = self.step;
|
||||
|
||||
let value = match axis {
|
||||
Axis::Horizontal => {
|
||||
let relative = (position.x - bounds.left()) / bounds.size.width;
|
||||
min + (max - min) * relative
|
||||
}
|
||||
Axis::Vertical => {
|
||||
let relative = (position.y - bounds.top()) / bounds.size.height;
|
||||
max - (max - min) * relative
|
||||
}
|
||||
};
|
||||
|
||||
let value = (value / step).round() * step;
|
||||
|
||||
self.value = value.clamp(self.min, self.max);
|
||||
cx.emit(SliderEvent::Change(self.value));
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn render_thumb(&self, cx: &mut ViewContext<Self>) -> impl gpui::IntoElement {
|
||||
let value = self.value;
|
||||
let entity_id = cx.entity_id();
|
||||
|
||||
div()
|
||||
.id("slider-thumb")
|
||||
.on_drag(DragThumb(entity_id), |drag, _, cx| {
|
||||
cx.stop_propagation();
|
||||
cx.new_view(|_| drag.clone())
|
||||
})
|
||||
.on_drag_move(cx.listener(
|
||||
move |view, e: &DragMoveEvent<DragThumb>, cx| match e.drag(cx) {
|
||||
DragThumb(id) => {
|
||||
if *id != entity_id {
|
||||
return;
|
||||
}
|
||||
|
||||
// set value by mouse position
|
||||
view.update_value_by_position(e.event.position, cx)
|
||||
}
|
||||
},
|
||||
))
|
||||
.absolute()
|
||||
.top(px(-5.))
|
||||
.left(relative(self.relative_value()))
|
||||
.ml(-px(8.))
|
||||
.size_4()
|
||||
.rounded_full()
|
||||
.border_1()
|
||||
.border_color(cx.theme().slider_bar.opacity(0.9))
|
||||
.when(cx.theme().shadow, |this| this.shadow_md())
|
||||
.bg(cx.theme().slider_thumb)
|
||||
.tooltip(move |cx| Tooltip::new(format!("{}", value), cx))
|
||||
}
|
||||
|
||||
fn on_mouse_down(&mut self, event: &MouseDownEvent, cx: &mut gpui::ViewContext<Self>) {
|
||||
self.update_value_by_position(event.position, cx);
|
||||
}
|
||||
}
|
||||
|
||||
impl EventEmitter<SliderEvent> for Slider {}
|
||||
|
||||
impl Render for Slider {
|
||||
fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
|
||||
div()
|
||||
.id("slider")
|
||||
.on_mouse_down(MouseButton::Left, cx.listener(Self::on_mouse_down))
|
||||
.h_5()
|
||||
.child(
|
||||
div()
|
||||
.id("slider-bar")
|
||||
.relative()
|
||||
.w_full()
|
||||
.my_1p5()
|
||||
.h_1p5()
|
||||
.bg(cx.theme().slider_bar.opacity(0.2))
|
||||
.active(|this| this.bg(cx.theme().slider_bar.opacity(0.4)))
|
||||
.rounded(px(3.))
|
||||
.child(
|
||||
div()
|
||||
.absolute()
|
||||
.top_0()
|
||||
.left_0()
|
||||
.h_full()
|
||||
.w(relative(self.relative_value()))
|
||||
.bg(cx.theme().slider_bar)
|
||||
.rounded_l(px(3.)),
|
||||
)
|
||||
.child(self.render_thumb(cx))
|
||||
.child({
|
||||
let view = cx.view().clone();
|
||||
canvas(
|
||||
move |bounds, cx| view.update(cx, |r, _| r.bounds = bounds),
|
||||
|_, _, _| {},
|
||||
)
|
||||
.absolute()
|
||||
.size_full()
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
426
crates/ui/src/styled.rs
Normal file
426
crates/ui/src/styled.rs
Normal file
@@ -0,0 +1,426 @@
|
||||
use std::fmt::{self, Display, Formatter};
|
||||
|
||||
use crate::{
|
||||
scroll::{Scrollable, ScrollbarAxis},
|
||||
theme::ActiveTheme,
|
||||
};
|
||||
use gpui::{
|
||||
div, px, Axis, Div, Edges, Element, ElementId, EntityId, FocusHandle, Pixels, Styled,
|
||||
WindowContext,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Returns a `Div` as horizontal flex layout.
|
||||
pub fn h_flex() -> Div {
|
||||
div().h_flex()
|
||||
}
|
||||
|
||||
/// Returns a `Div` as vertical flex layout.
|
||||
pub fn v_flex() -> Div {
|
||||
div().v_flex()
|
||||
}
|
||||
|
||||
macro_rules! font_weight {
|
||||
($fn:ident, $const:ident) => {
|
||||
/// [docs](https://tailwindcss.com/docs/font-weight)
|
||||
fn $fn(self) -> Self {
|
||||
self.font_weight(gpui::FontWeight::$const)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/// Extends [`gpui::Styled`] with specific styling methods.
|
||||
pub trait StyledExt: Styled + Sized {
|
||||
/// Apply self into a horizontal flex layout.
|
||||
fn h_flex(self) -> Self {
|
||||
self.flex().flex_row().items_center()
|
||||
}
|
||||
|
||||
/// Apply self into a vertical flex layout.
|
||||
fn v_flex(self) -> Self {
|
||||
self.flex().flex_col()
|
||||
}
|
||||
|
||||
/// Render a border with a width of 1px, color red
|
||||
fn debug_red(self) -> Self {
|
||||
if cfg!(debug_assertions) {
|
||||
self.border_1().border_color(crate::red_500())
|
||||
} else {
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// Render a border with a width of 1px, color blue
|
||||
fn debug_blue(self) -> Self {
|
||||
if cfg!(debug_assertions) {
|
||||
self.border_1().border_color(crate::blue_500())
|
||||
} else {
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// Render a border with a width of 1px, color yellow
|
||||
fn debug_yellow(self) -> Self {
|
||||
if cfg!(debug_assertions) {
|
||||
self.border_1().border_color(crate::yellow_500())
|
||||
} else {
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// Render a border with a width of 1px, color green
|
||||
fn debug_green(self) -> Self {
|
||||
if cfg!(debug_assertions) {
|
||||
self.border_1().border_color(crate::green_500())
|
||||
} else {
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// Render a border with a width of 1px, color pink
|
||||
fn debug_pink(self) -> Self {
|
||||
if cfg!(debug_assertions) {
|
||||
self.border_1().border_color(crate::pink_500())
|
||||
} else {
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// Render a 1px blue border, when if the element is focused
|
||||
fn debug_focused(self, focus_handle: &FocusHandle, cx: &WindowContext) -> Self {
|
||||
if cfg!(debug_assertions) {
|
||||
if focus_handle.contains_focused(cx) {
|
||||
self.debug_blue()
|
||||
} else {
|
||||
self
|
||||
}
|
||||
} else {
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// Render a border with a width of 1px, color ring color
|
||||
fn outline(self, cx: &WindowContext) -> Self {
|
||||
self.border_color(cx.theme().ring)
|
||||
}
|
||||
|
||||
/// Wraps the element in a ScrollView.
|
||||
///
|
||||
/// Current this is only have a vertical scrollbar.
|
||||
fn scrollable(self, view_id: EntityId, axis: ScrollbarAxis) -> Scrollable<Self>
|
||||
where
|
||||
Self: Element,
|
||||
{
|
||||
Scrollable::new(view_id, self, axis)
|
||||
}
|
||||
|
||||
font_weight!(font_thin, THIN);
|
||||
font_weight!(font_extralight, EXTRA_LIGHT);
|
||||
font_weight!(font_light, LIGHT);
|
||||
font_weight!(font_normal, NORMAL);
|
||||
font_weight!(font_medium, MEDIUM);
|
||||
font_weight!(font_semibold, SEMIBOLD);
|
||||
font_weight!(font_bold, BOLD);
|
||||
font_weight!(font_extrabold, EXTRA_BOLD);
|
||||
font_weight!(font_black, BLACK);
|
||||
|
||||
/// Set as Popover style
|
||||
fn popover_style(self, cx: &mut WindowContext) -> Self {
|
||||
self.bg(cx.theme().popover)
|
||||
.border_1()
|
||||
.border_color(cx.theme().border)
|
||||
.shadow_lg()
|
||||
.rounded(px(cx.theme().radius))
|
||||
}
|
||||
}
|
||||
|
||||
impl<E: Styled> StyledExt for E {}
|
||||
|
||||
/// A size for elements.
|
||||
#[derive(Clone, Default, Copy, PartialEq, Eq, Debug, Deserialize, Serialize)]
|
||||
pub enum Size {
|
||||
Size(Pixels),
|
||||
XSmall,
|
||||
Small,
|
||||
#[default]
|
||||
Medium,
|
||||
Large,
|
||||
}
|
||||
|
||||
impl Size {
|
||||
/// Returns the height for table row.
|
||||
pub fn table_row_height(&self) -> Pixels {
|
||||
match self {
|
||||
Size::XSmall => px(26.),
|
||||
Size::Small => px(30.),
|
||||
Size::Large => px(40.),
|
||||
_ => px(32.),
|
||||
}
|
||||
}
|
||||
/// Returns the padding for a table cell.
|
||||
pub fn table_cell_padding(&self) -> Edges<Pixels> {
|
||||
match self {
|
||||
Size::XSmall => Edges {
|
||||
top: px(2.),
|
||||
bottom: px(2.),
|
||||
left: px(4.),
|
||||
right: px(4.),
|
||||
},
|
||||
Size::Small => Edges {
|
||||
top: px(3.),
|
||||
bottom: px(3.),
|
||||
left: px(6.),
|
||||
right: px(6.),
|
||||
},
|
||||
Size::Large => Edges {
|
||||
top: px(8.),
|
||||
bottom: px(8.),
|
||||
left: px(12.),
|
||||
right: px(12.),
|
||||
},
|
||||
_ => Edges {
|
||||
top: px(4.),
|
||||
bottom: px(4.),
|
||||
left: px(8.),
|
||||
right: px(8.),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Pixels> for Size {
|
||||
fn from(size: Pixels) -> Self {
|
||||
Size::Size(size)
|
||||
}
|
||||
}
|
||||
|
||||
/// A trait for defining element that can be selected.
|
||||
pub trait Selectable: Sized {
|
||||
fn element_id(&self) -> &ElementId;
|
||||
/// Set the selected state of the element.
|
||||
fn selected(self, selected: bool) -> Self;
|
||||
}
|
||||
|
||||
/// A trait for defining element that can be disabled.
|
||||
pub trait Disableable {
|
||||
/// Set the disabled state of the element.
|
||||
fn disabled(self, disabled: bool) -> Self;
|
||||
}
|
||||
|
||||
/// A trait for setting the size of an element.
|
||||
pub trait Sizable: Sized {
|
||||
/// Set the ui::Size of this element.
|
||||
///
|
||||
/// Also can receive a `ButtonSize` to convert to `IconSize`,
|
||||
/// Or a `Pixels` to set a custom size: `px(30.)`
|
||||
fn with_size(self, size: impl Into<Size>) -> Self;
|
||||
|
||||
/// Set to Size::Small
|
||||
fn small(self) -> Self {
|
||||
self.with_size(Size::Small)
|
||||
}
|
||||
|
||||
/// Set to Size::XSmall
|
||||
fn xsmall(self) -> Self {
|
||||
self.with_size(Size::XSmall)
|
||||
}
|
||||
|
||||
/// Set to Size::Medium
|
||||
fn large(self) -> Self {
|
||||
self.with_size(Size::Large)
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(unused)]
|
||||
pub trait StyleSized<T: Styled> {
|
||||
fn input_text_size(self, size: Size) -> Self;
|
||||
fn input_size(self, size: Size) -> Self;
|
||||
fn input_pl(self, size: Size) -> Self;
|
||||
fn input_pr(self, size: Size) -> Self;
|
||||
fn input_px(self, size: Size) -> Self;
|
||||
fn input_py(self, size: Size) -> Self;
|
||||
fn input_h(self, size: Size) -> Self;
|
||||
fn list_size(self, size: Size) -> Self;
|
||||
fn list_px(self, size: Size) -> Self;
|
||||
fn list_py(self, size: Size) -> Self;
|
||||
/// Apply size with the given `Size`.
|
||||
fn size_with(self, size: Size) -> Self;
|
||||
/// Apply the table cell size (Font size, padding) with the given `Size`.
|
||||
fn table_cell_size(self, size: Size) -> Self;
|
||||
}
|
||||
|
||||
impl<T: Styled> StyleSized<T> for T {
|
||||
fn input_text_size(self, size: Size) -> Self {
|
||||
match size {
|
||||
Size::XSmall => self.text_xs(),
|
||||
Size::Small => self.text_sm(),
|
||||
Size::Medium => self.text_base(),
|
||||
Size::Large => self.text_lg(),
|
||||
Size::Size(size) => self.text_size(size),
|
||||
}
|
||||
}
|
||||
|
||||
fn input_size(self, size: Size) -> Self {
|
||||
self.input_px(size).input_py(size).input_h(size)
|
||||
}
|
||||
|
||||
fn input_pl(self, size: Size) -> Self {
|
||||
match size {
|
||||
Size::Large => self.pl_5(),
|
||||
Size::Medium => self.pl_3(),
|
||||
_ => self.pl_2(),
|
||||
}
|
||||
}
|
||||
|
||||
fn input_pr(self, size: Size) -> Self {
|
||||
match size {
|
||||
Size::Large => self.pr_5(),
|
||||
Size::Medium => self.pr_3(),
|
||||
_ => self.pr_2(),
|
||||
}
|
||||
}
|
||||
|
||||
fn input_px(self, size: Size) -> Self {
|
||||
match size {
|
||||
Size::Large => self.px_5(),
|
||||
Size::Medium => self.px_3(),
|
||||
_ => self.px_2(),
|
||||
}
|
||||
}
|
||||
|
||||
fn input_py(self, size: Size) -> Self {
|
||||
match size {
|
||||
Size::Large => self.py_5(),
|
||||
Size::Medium => self.py_2(),
|
||||
_ => self.py_1(),
|
||||
}
|
||||
}
|
||||
|
||||
fn input_h(self, size: Size) -> Self {
|
||||
match size {
|
||||
Size::Large => self.h_11(),
|
||||
Size::Medium => self.h_8(),
|
||||
_ => self.h(px(26.)),
|
||||
}
|
||||
.input_text_size(size)
|
||||
}
|
||||
|
||||
fn list_size(self, size: Size) -> Self {
|
||||
self.list_px(size).list_py(size).input_text_size(size)
|
||||
}
|
||||
|
||||
fn list_px(self, size: Size) -> Self {
|
||||
match size {
|
||||
Size::Small => self.px_2(),
|
||||
_ => self.px_3(),
|
||||
}
|
||||
}
|
||||
|
||||
fn list_py(self, size: Size) -> Self {
|
||||
match size {
|
||||
Size::Large => self.py_2(),
|
||||
Size::Medium => self.py_1(),
|
||||
Size::Small => self.py_0p5(),
|
||||
_ => self.py_1(),
|
||||
}
|
||||
}
|
||||
|
||||
fn size_with(self, size: Size) -> Self {
|
||||
match size {
|
||||
Size::Large => self.size_11(),
|
||||
Size::Medium => self.size_8(),
|
||||
Size::Small => self.size_5(),
|
||||
Size::XSmall => self.size_4(),
|
||||
Size::Size(size) => self.size(size),
|
||||
}
|
||||
}
|
||||
|
||||
fn table_cell_size(self, size: Size) -> Self {
|
||||
let padding = size.table_cell_padding();
|
||||
match size {
|
||||
Size::XSmall => self.text_sm(),
|
||||
Size::Small => self.text_sm(),
|
||||
_ => self,
|
||||
}
|
||||
.pl(padding.left)
|
||||
.pr(padding.right)
|
||||
.pt(padding.top)
|
||||
.pb(padding.bottom)
|
||||
}
|
||||
}
|
||||
|
||||
pub trait AxisExt {
|
||||
fn is_horizontal(self) -> bool;
|
||||
fn is_vertical(self) -> bool;
|
||||
}
|
||||
|
||||
impl AxisExt for Axis {
|
||||
fn is_horizontal(self) -> bool {
|
||||
self == Axis::Horizontal
|
||||
}
|
||||
|
||||
fn is_vertical(self) -> bool {
|
||||
self == Axis::Vertical
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
pub enum Placement {
|
||||
Top,
|
||||
Bottom,
|
||||
Left,
|
||||
Right,
|
||||
}
|
||||
|
||||
impl Display for Placement {
|
||||
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Placement::Top => write!(f, "Top"),
|
||||
Placement::Bottom => write!(f, "Bottom"),
|
||||
Placement::Left => write!(f, "Left"),
|
||||
Placement::Right => write!(f, "Right"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Placement {
|
||||
pub fn is_horizontal(&self) -> bool {
|
||||
match self {
|
||||
Placement::Left | Placement::Right => true,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_vertical(&self) -> bool {
|
||||
match self {
|
||||
Placement::Top | Placement::Bottom => true,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn axis(&self) -> Axis {
|
||||
match self {
|
||||
Placement::Top | Placement::Bottom => Axis::Vertical,
|
||||
Placement::Left | Placement::Right => Axis::Horizontal,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A enum for defining the side of the element.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
pub enum Side {
|
||||
Left,
|
||||
Right,
|
||||
}
|
||||
|
||||
impl Side {
|
||||
pub(crate) fn is_left(&self) -> bool {
|
||||
matches!(self, Self::Left)
|
||||
}
|
||||
}
|
||||
|
||||
/// A trait for defining element that can be collapsed.
|
||||
pub trait Collapsible {
|
||||
fn collapsed(self, collapsed: bool) -> Self;
|
||||
fn is_collapsed(&self) -> bool;
|
||||
}
|
||||
295
crates/ui/src/svg_img.rs
Normal file
295
crates/ui/src/svg_img.rs
Normal file
@@ -0,0 +1,295 @@
|
||||
use std::{
|
||||
hash::Hash,
|
||||
ops::Deref,
|
||||
sync::{Arc, LazyLock},
|
||||
};
|
||||
|
||||
use gpui::{
|
||||
px, size, AppContext, Asset, Bounds, Element, Hitbox, ImageCacheError, InteractiveElement,
|
||||
Interactivity, IntoElement, IsZero, Pixels, RenderImage, SharedString, Size, StyleRefinement,
|
||||
Styled, WindowContext,
|
||||
};
|
||||
use image::Frame;
|
||||
use smallvec::SmallVec;
|
||||
|
||||
use image::ImageBuffer;
|
||||
|
||||
use crate::Assets;
|
||||
|
||||
const SCALE: f32 = 2.;
|
||||
const FONT_PATH: &str = "fonts/NotoSans-Regular.ttf";
|
||||
static OPTIONS: LazyLock<usvg::Options> = LazyLock::new(|| {
|
||||
let mut options = usvg::Options::default();
|
||||
if let Some(font_data) = Assets::get(FONT_PATH).map(|f| f.data) {
|
||||
options.fontdb_mut().load_font_data(font_data.into());
|
||||
}
|
||||
options
|
||||
});
|
||||
|
||||
#[derive(Debug, Clone, Hash)]
|
||||
pub enum SvgSource {
|
||||
/// A svg bytes
|
||||
Data(Arc<[u8]>),
|
||||
/// An asset path
|
||||
Path(SharedString),
|
||||
}
|
||||
|
||||
impl From<&[u8]> for SvgSource {
|
||||
fn from(data: &[u8]) -> Self {
|
||||
Self::Data(data.into())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Arc<[u8]>> for SvgSource {
|
||||
fn from(data: Arc<[u8]>) -> Self {
|
||||
Self::Data(data)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<SharedString> for SvgSource {
|
||||
fn from(path: SharedString) -> Self {
|
||||
Self::Path(path)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&'static str> for SvgSource {
|
||||
fn from(path: &'static str) -> Self {
|
||||
Self::Path(path.into())
|
||||
}
|
||||
}
|
||||
|
||||
impl Clone for SvgImg {
|
||||
fn clone(&self) -> Self {
|
||||
Self {
|
||||
interactivity: Interactivity::default(),
|
||||
source: self.source.clone(),
|
||||
size: self.size,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum Image {}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct ImageSource {
|
||||
source: SvgSource,
|
||||
size: Size<Pixels>,
|
||||
}
|
||||
|
||||
impl Hash for ImageSource {
|
||||
/// Hash to to control the Asset cache
|
||||
fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
|
||||
self.source.hash(state);
|
||||
}
|
||||
}
|
||||
|
||||
impl Asset for Image {
|
||||
type Source = ImageSource;
|
||||
type Output = Result<Arc<RenderImage>, ImageCacheError>;
|
||||
|
||||
fn load(
|
||||
source: Self::Source,
|
||||
cx: &mut AppContext,
|
||||
) -> impl std::future::Future<Output = Self::Output> + Send + 'static {
|
||||
let asset_source = cx.asset_source().clone();
|
||||
|
||||
async move {
|
||||
let size = source.size;
|
||||
if size.width.is_zero() || size.height.is_zero() {
|
||||
return Err(usvg::Error::InvalidSize.into());
|
||||
}
|
||||
let size = Size {
|
||||
width: (size.width * SCALE).ceil(),
|
||||
height: (size.height * SCALE).ceil(),
|
||||
};
|
||||
|
||||
let bytes = match source.source {
|
||||
SvgSource::Data(data) => data,
|
||||
SvgSource::Path(path) => {
|
||||
if let Ok(Some(data)) = asset_source.load(&path) {
|
||||
data.deref().to_vec().into()
|
||||
} else {
|
||||
Err(std::io::Error::other(format!(
|
||||
"failed to load svg image from path: {}",
|
||||
path
|
||||
)))
|
||||
.map_err(|e| ImageCacheError::Io(Arc::new(e)))?
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let tree = usvg::Tree::from_data(&bytes, &OPTIONS)?;
|
||||
|
||||
let mut pixmap =
|
||||
resvg::tiny_skia::Pixmap::new(size.width.0 as u32, size.height.0 as u32)
|
||||
.ok_or(usvg::Error::InvalidSize)?;
|
||||
|
||||
let transform = resvg::tiny_skia::Transform::from_scale(SCALE, SCALE);
|
||||
|
||||
resvg::render(&tree, transform, &mut pixmap.as_mut());
|
||||
|
||||
let mut buffer = ImageBuffer::from_raw(pixmap.width(), pixmap.height(), pixmap.take())
|
||||
.expect("invalid svg image buffer");
|
||||
|
||||
// Convert from RGBA with premultiplied alpha to BGRA with straight alpha.
|
||||
for pixel in buffer.chunks_exact_mut(4) {
|
||||
pixel.swap(0, 2);
|
||||
if pixel[3] > 0 {
|
||||
let a = pixel[3] as f32 / 255.;
|
||||
pixel[0] = (pixel[0] as f32 / a) as u8;
|
||||
pixel[1] = (pixel[1] as f32 / a) as u8;
|
||||
pixel[2] = (pixel[2] as f32 / a) as u8;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Arc::new(RenderImage::new(SmallVec::from_elem(
|
||||
Frame::new(buffer),
|
||||
1,
|
||||
))))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct SvgImg {
|
||||
interactivity: Interactivity,
|
||||
source: Option<SvgSource>,
|
||||
size: Size<Pixels>,
|
||||
}
|
||||
|
||||
impl SvgImg {
|
||||
/// Create a new svg image element.
|
||||
///
|
||||
/// The `src_width` and `src_height` are the original width and height of the svg image.
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
interactivity: Interactivity::default(),
|
||||
source: None,
|
||||
size: Size::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the path of the svg image from the asset.
|
||||
///
|
||||
/// The `size` argument is the size of the original svg image.
|
||||
#[must_use]
|
||||
pub fn source(
|
||||
mut self,
|
||||
source: impl Into<SvgSource>,
|
||||
width: impl Into<Pixels>,
|
||||
height: impl Into<Pixels>,
|
||||
) -> Self {
|
||||
self.source = Some(source.into());
|
||||
self.size = size(width.into(), height.into());
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoElement for SvgImg {
|
||||
type Element = Self;
|
||||
|
||||
fn into_element(self) -> Self::Element {
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl Element for SvgImg {
|
||||
type RequestLayoutState = ();
|
||||
type PrepaintState = Option<Hitbox>;
|
||||
|
||||
fn id(&self) -> Option<gpui::ElementId> {
|
||||
self.interactivity.element_id.clone()
|
||||
}
|
||||
|
||||
fn request_layout(
|
||||
&mut self,
|
||||
global_id: Option<&gpui::GlobalElementId>,
|
||||
cx: &mut WindowContext,
|
||||
) -> (gpui::LayoutId, Self::RequestLayoutState) {
|
||||
let layout_id = self
|
||||
.interactivity
|
||||
.request_layout(global_id, cx, |style, cx| cx.request_layout(style, None));
|
||||
|
||||
(layout_id, ())
|
||||
}
|
||||
|
||||
fn prepaint(
|
||||
&mut self,
|
||||
global_id: Option<&gpui::GlobalElementId>,
|
||||
bounds: gpui::Bounds<gpui::Pixels>,
|
||||
_: &mut Self::RequestLayoutState,
|
||||
cx: &mut WindowContext,
|
||||
) -> Self::PrepaintState {
|
||||
self.interactivity
|
||||
.prepaint(global_id, bounds, bounds.size, cx, |_, _, hitbox, _| hitbox)
|
||||
}
|
||||
|
||||
fn paint(
|
||||
&mut self,
|
||||
global_id: Option<&gpui::GlobalElementId>,
|
||||
bounds: gpui::Bounds<gpui::Pixels>,
|
||||
_: &mut Self::RequestLayoutState,
|
||||
hitbox: &mut Self::PrepaintState,
|
||||
cx: &mut WindowContext,
|
||||
) {
|
||||
let source = self.source.clone();
|
||||
|
||||
self.interactivity
|
||||
.paint(global_id, bounds, hitbox.as_ref(), cx, |_style, cx| {
|
||||
let size = self.size;
|
||||
|
||||
let data = if let Some(source) = source {
|
||||
match cx.use_asset::<Image>(&ImageSource { source, size }) {
|
||||
Some(Ok(data)) => Some(data),
|
||||
_ => None,
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
if let Some(data) = data {
|
||||
// To calculate the ratio of the original image size to the container bounds size.
|
||||
// Scale by shortest side (width or height) to get a fit image.
|
||||
// And center the image in the container bounds.
|
||||
let ratio = if bounds.size.width < bounds.size.height {
|
||||
bounds.size.width / size.width
|
||||
} else {
|
||||
bounds.size.height / size.height
|
||||
};
|
||||
|
||||
let ratio = ratio.min(1.0);
|
||||
|
||||
let new_size = gpui::Size {
|
||||
width: size.width * ratio,
|
||||
height: size.height * ratio,
|
||||
};
|
||||
let new_origin = gpui::Point {
|
||||
x: bounds.origin.x + px(((bounds.size.width - new_size.width) / 2.).into()),
|
||||
y: bounds.origin.y
|
||||
+ px(((bounds.size.height - new_size.height) / 2.).into()),
|
||||
};
|
||||
|
||||
let img_bounds = Bounds {
|
||||
origin: new_origin.map(|origin| origin.floor()),
|
||||
size: new_size.map(|size| size.ceil()),
|
||||
};
|
||||
|
||||
match cx.paint_image(img_bounds, px(0.).into(), data, 0, false) {
|
||||
Ok(_) => {}
|
||||
Err(err) => eprintln!("failed to paint svg image: {:?}", err),
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Styled for SvgImg {
|
||||
fn style(&mut self) -> &mut StyleRefinement {
|
||||
&mut self.interactivity.base_style
|
||||
}
|
||||
}
|
||||
|
||||
impl InteractiveElement for SvgImg {
|
||||
fn interactivity(&mut self) -> &mut Interactivity {
|
||||
&mut self.interactivity
|
||||
}
|
||||
}
|
||||
234
crates/ui/src/switch.rs
Normal file
234
crates/ui/src/switch.rs
Normal file
@@ -0,0 +1,234 @@
|
||||
use crate::{h_flex, theme::ActiveTheme, Disableable, Side, Sizable, Size};
|
||||
use gpui::{
|
||||
div, prelude::FluentBuilder as _, px, Animation, AnimationExt as _, AnyElement, Element,
|
||||
ElementId, GlobalElementId, InteractiveElement, IntoElement, LayoutId, ParentElement as _,
|
||||
SharedString, Styled as _, WindowContext,
|
||||
};
|
||||
use std::{cell::RefCell, rc::Rc, time::Duration};
|
||||
|
||||
pub struct Switch {
|
||||
id: ElementId,
|
||||
checked: bool,
|
||||
disabled: bool,
|
||||
label: Option<SharedString>,
|
||||
label_side: Side,
|
||||
on_click: Option<Rc<dyn Fn(&bool, &mut WindowContext)>>,
|
||||
size: Size,
|
||||
}
|
||||
|
||||
impl Switch {
|
||||
pub fn new(id: impl Into<ElementId>) -> Self {
|
||||
let id: ElementId = id.into();
|
||||
Self {
|
||||
id: id.clone(),
|
||||
checked: false,
|
||||
disabled: false,
|
||||
label: None,
|
||||
on_click: None,
|
||||
label_side: Side::Right,
|
||||
size: Size::Medium,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn checked(mut self, checked: bool) -> Self {
|
||||
self.checked = checked;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn label(mut self, label: impl Into<SharedString>) -> Self {
|
||||
self.label = Some(label.into());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn on_click<F>(mut self, handler: F) -> Self
|
||||
where
|
||||
F: Fn(&bool, &mut WindowContext) + 'static,
|
||||
{
|
||||
self.on_click = Some(Rc::new(handler));
|
||||
self
|
||||
}
|
||||
|
||||
pub fn label_side(mut self, label_side: Side) -> Self {
|
||||
self.label_side = label_side;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl Sizable for Switch {
|
||||
fn with_size(mut self, size: impl Into<Size>) -> Self {
|
||||
self.size = size.into();
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl Disableable for Switch {
|
||||
fn disabled(mut self, disabled: bool) -> Self {
|
||||
self.disabled = disabled;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoElement for Switch {
|
||||
type Element = Self;
|
||||
|
||||
fn into_element(self) -> Self::Element {
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct SwitchState {
|
||||
prev_checked: Rc<RefCell<Option<bool>>>,
|
||||
}
|
||||
|
||||
impl Element for Switch {
|
||||
type RequestLayoutState = AnyElement;
|
||||
|
||||
type PrepaintState = ();
|
||||
|
||||
fn id(&self) -> Option<ElementId> {
|
||||
Some(self.id.clone())
|
||||
}
|
||||
|
||||
fn request_layout(
|
||||
&mut self,
|
||||
global_id: Option<&GlobalElementId>,
|
||||
cx: &mut WindowContext,
|
||||
) -> (LayoutId, Self::RequestLayoutState) {
|
||||
cx.with_element_state::<SwitchState, _>(global_id.unwrap(), move |state, cx| {
|
||||
let state = state.unwrap_or_default();
|
||||
|
||||
let theme = cx.theme();
|
||||
let checked = self.checked;
|
||||
let on_click = self.on_click.clone();
|
||||
|
||||
let (bg, toggle_bg) = match self.checked {
|
||||
true => (theme.primary, theme.background),
|
||||
false => (theme.input, theme.background),
|
||||
};
|
||||
|
||||
let (bg, toggle_bg) = match self.disabled {
|
||||
true => (bg.opacity(0.3), toggle_bg.opacity(0.8)),
|
||||
false => (bg, toggle_bg),
|
||||
};
|
||||
|
||||
let (bg_width, bg_height) = match self.size {
|
||||
Size::XSmall | Size::Small => (px(28.), px(16.)),
|
||||
_ => (px(36.), px(20.)),
|
||||
};
|
||||
let bar_width = match self.size {
|
||||
Size::XSmall | Size::Small => px(12.),
|
||||
_ => px(16.),
|
||||
};
|
||||
let inset = px(2.);
|
||||
|
||||
let mut element = h_flex()
|
||||
.id(self.id.clone())
|
||||
.items_center()
|
||||
.gap_2()
|
||||
.when(self.label_side.is_left(), |this| this.flex_row_reverse())
|
||||
.child(
|
||||
// Switch Bar
|
||||
div()
|
||||
.id(self.id.clone())
|
||||
.w(bg_width)
|
||||
.h(bg_height)
|
||||
.rounded(bg_height / 2.)
|
||||
.flex()
|
||||
.items_center()
|
||||
.border(inset)
|
||||
.border_color(theme.transparent)
|
||||
.bg(bg)
|
||||
.when(!self.disabled, |this| this.cursor_pointer())
|
||||
.child(
|
||||
// Switch Toggle
|
||||
div()
|
||||
.rounded_full()
|
||||
.bg(toggle_bg)
|
||||
.size(bar_width)
|
||||
.map(|this| {
|
||||
let prev_checked = state.prev_checked.clone();
|
||||
if !self.disabled
|
||||
&& prev_checked
|
||||
.borrow()
|
||||
.map_or(false, |prev| prev != checked)
|
||||
{
|
||||
let dur = Duration::from_secs_f64(0.15);
|
||||
cx.spawn(|cx| async move {
|
||||
cx.background_executor().timer(dur).await;
|
||||
|
||||
*prev_checked.borrow_mut() = Some(checked);
|
||||
})
|
||||
.detach();
|
||||
this.with_animation(
|
||||
ElementId::NamedInteger(
|
||||
"move".into(),
|
||||
checked as usize,
|
||||
),
|
||||
Animation::new(dur),
|
||||
move |this, delta| {
|
||||
let max_x = bg_width - bar_width - inset * 2;
|
||||
let x = if checked {
|
||||
max_x * delta
|
||||
} else {
|
||||
max_x - max_x * delta
|
||||
};
|
||||
this.left(x)
|
||||
},
|
||||
)
|
||||
.into_any_element()
|
||||
} else {
|
||||
let max_x = bg_width - bar_width - inset * 2;
|
||||
let x = if checked { max_x } else { px(0.) };
|
||||
this.left(x).into_any_element()
|
||||
}
|
||||
}),
|
||||
),
|
||||
)
|
||||
.when_some(self.label.clone(), |this, label| {
|
||||
this.child(div().child(label).map(|this| match self.size {
|
||||
Size::XSmall | Size::Small => this.text_sm(),
|
||||
_ => this.text_base(),
|
||||
}))
|
||||
})
|
||||
.when_some(
|
||||
on_click
|
||||
.as_ref()
|
||||
.map(|c| c.clone())
|
||||
.filter(|_| !self.disabled),
|
||||
|this, on_click| {
|
||||
let prev_checked = state.prev_checked.clone();
|
||||
this.on_mouse_down(gpui::MouseButton::Left, move |_, cx| {
|
||||
cx.stop_propagation();
|
||||
*prev_checked.borrow_mut() = Some(checked);
|
||||
on_click(&!checked, cx);
|
||||
})
|
||||
},
|
||||
)
|
||||
.into_any_element();
|
||||
|
||||
((element.request_layout(cx), element), state)
|
||||
})
|
||||
}
|
||||
|
||||
fn prepaint(
|
||||
&mut self,
|
||||
_: Option<&gpui::GlobalElementId>,
|
||||
_: gpui::Bounds<gpui::Pixels>,
|
||||
element: &mut Self::RequestLayoutState,
|
||||
cx: &mut WindowContext,
|
||||
) {
|
||||
element.prepaint(cx);
|
||||
}
|
||||
|
||||
fn paint(
|
||||
&mut self,
|
||||
_: Option<&gpui::GlobalElementId>,
|
||||
_: gpui::Bounds<gpui::Pixels>,
|
||||
element: &mut Self::RequestLayoutState,
|
||||
_: &mut Self::PrepaintState,
|
||||
cx: &mut WindowContext,
|
||||
) {
|
||||
element.paint(cx)
|
||||
}
|
||||
}
|
||||
5
crates/ui/src/tab.rs
Normal file
5
crates/ui/src/tab.rs
Normal file
@@ -0,0 +1,5 @@
|
||||
mod tab;
|
||||
mod tab_bar;
|
||||
|
||||
pub use tab::*;
|
||||
pub use tab_bar::*;
|
||||
99
crates/ui/src/tab/tab.rs
Normal file
99
crates/ui/src/tab/tab.rs
Normal file
@@ -0,0 +1,99 @@
|
||||
use crate::theme::ActiveTheme;
|
||||
use crate::Selectable;
|
||||
use gpui::prelude::FluentBuilder as _;
|
||||
use gpui::{
|
||||
div, px, AnyElement, Div, ElementId, InteractiveElement, IntoElement, ParentElement as _,
|
||||
RenderOnce, Stateful, StatefulInteractiveElement, Styled, WindowContext,
|
||||
};
|
||||
|
||||
#[derive(IntoElement)]
|
||||
pub struct Tab {
|
||||
id: ElementId,
|
||||
base: Stateful<Div>,
|
||||
label: AnyElement,
|
||||
prefix: Option<AnyElement>,
|
||||
suffix: Option<AnyElement>,
|
||||
disabled: bool,
|
||||
selected: bool,
|
||||
}
|
||||
|
||||
impl Tab {
|
||||
pub fn new(id: impl Into<ElementId>, label: impl IntoElement) -> Self {
|
||||
let id: ElementId = id.into();
|
||||
Self {
|
||||
id: id.clone(),
|
||||
base: div().id(id).gap_1().py_1p5().px_3().h(px(30.)),
|
||||
label: label.into_any_element(),
|
||||
disabled: false,
|
||||
selected: false,
|
||||
prefix: None,
|
||||
suffix: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the left side of the tab
|
||||
pub fn prefix(mut self, prefix: impl Into<AnyElement>) -> Self {
|
||||
self.prefix = Some(prefix.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the right side of the tab
|
||||
pub fn suffix(mut self, suffix: impl Into<AnyElement>) -> Self {
|
||||
self.suffix = Some(suffix.into());
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl Selectable for Tab {
|
||||
fn element_id(&self) -> &ElementId {
|
||||
&self.id
|
||||
}
|
||||
|
||||
fn selected(mut self, selected: bool) -> Self {
|
||||
self.selected = selected;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl InteractiveElement for Tab {
|
||||
fn interactivity(&mut self) -> &mut gpui::Interactivity {
|
||||
self.base.interactivity()
|
||||
}
|
||||
}
|
||||
|
||||
impl StatefulInteractiveElement for Tab {}
|
||||
|
||||
impl Styled for Tab {
|
||||
fn style(&mut self) -> &mut gpui::StyleRefinement {
|
||||
self.base.style()
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for Tab {
|
||||
fn render(self, cx: &mut WindowContext) -> impl IntoElement {
|
||||
let (text_color, bg_color) = match (self.selected, self.disabled) {
|
||||
(true, _) => (cx.theme().tab_active_foreground, cx.theme().tab_active),
|
||||
(false, true) => (cx.theme().tab_foreground.opacity(0.5), cx.theme().tab),
|
||||
(false, false) => (cx.theme().muted_foreground, cx.theme().tab),
|
||||
};
|
||||
|
||||
self.base
|
||||
.flex()
|
||||
.items_center()
|
||||
.flex_shrink_0()
|
||||
.cursor_pointer()
|
||||
.overflow_hidden()
|
||||
.text_color(text_color)
|
||||
.bg(bg_color)
|
||||
.border_x_1()
|
||||
.border_color(cx.theme().transparent)
|
||||
.when(self.selected, |this| this.border_color(cx.theme().border))
|
||||
.text_sm()
|
||||
.when(self.disabled, |this| this)
|
||||
.when_some(self.prefix, |this, prefix| {
|
||||
this.child(prefix).text_color(text_color)
|
||||
})
|
||||
.child(div().text_ellipsis().child(self.label))
|
||||
.when_some(self.suffix, |this, suffix| this.child(suffix))
|
||||
}
|
||||
}
|
||||
95
crates/ui/src/tab/tab_bar.rs
Normal file
95
crates/ui/src/tab/tab_bar.rs
Normal file
@@ -0,0 +1,95 @@
|
||||
use crate::h_flex;
|
||||
use crate::theme::ActiveTheme;
|
||||
use gpui::prelude::FluentBuilder as _;
|
||||
use gpui::{
|
||||
div, AnyElement, Div, ElementId, IntoElement, ParentElement, RenderOnce, ScrollHandle,
|
||||
StatefulInteractiveElement as _, Styled, WindowContext,
|
||||
};
|
||||
use gpui::{px, InteractiveElement};
|
||||
use smallvec::SmallVec;
|
||||
|
||||
#[derive(IntoElement)]
|
||||
pub struct TabBar {
|
||||
base: Div,
|
||||
id: ElementId,
|
||||
scroll_handle: ScrollHandle,
|
||||
prefix: Option<AnyElement>,
|
||||
suffix: Option<AnyElement>,
|
||||
children: SmallVec<[AnyElement; 2]>,
|
||||
}
|
||||
|
||||
impl TabBar {
|
||||
pub fn new(id: impl Into<ElementId>) -> Self {
|
||||
Self {
|
||||
base: div().px(px(-1.)),
|
||||
id: id.into(),
|
||||
children: SmallVec::new(),
|
||||
scroll_handle: ScrollHandle::new(),
|
||||
prefix: None,
|
||||
suffix: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Track the scroll of the TabBar
|
||||
pub fn track_scroll(mut self, scroll_handle: ScrollHandle) -> Self {
|
||||
self.scroll_handle = scroll_handle;
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the prefix element of the TabBar
|
||||
pub fn prefix(mut self, prefix: impl IntoElement) -> Self {
|
||||
self.prefix = Some(prefix.into_any_element());
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the suffix element of the TabBar
|
||||
pub fn suffix(mut self, suffix: impl IntoElement) -> Self {
|
||||
self.suffix = Some(suffix.into_any_element());
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl ParentElement for TabBar {
|
||||
fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
|
||||
self.children.extend(elements)
|
||||
}
|
||||
}
|
||||
|
||||
impl Styled for TabBar {
|
||||
fn style(&mut self) -> &mut gpui::StyleRefinement {
|
||||
self.base.style()
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for TabBar {
|
||||
fn render(self, cx: &mut WindowContext) -> impl IntoElement {
|
||||
self.base
|
||||
.id(self.id)
|
||||
.group("tab-bar")
|
||||
.relative()
|
||||
.flex()
|
||||
.flex_none()
|
||||
.items_center()
|
||||
.bg(cx.theme().tab_bar)
|
||||
.text_color(cx.theme().tab_foreground)
|
||||
.child(
|
||||
div()
|
||||
.id("border-b")
|
||||
.absolute()
|
||||
.bottom_0()
|
||||
.size_full()
|
||||
.border_b_1()
|
||||
.border_color(cx.theme().border),
|
||||
)
|
||||
.when_some(self.prefix, |this, prefix| this.child(prefix))
|
||||
.child(
|
||||
h_flex()
|
||||
.id("tabs")
|
||||
.flex_grow()
|
||||
.overflow_x_scroll()
|
||||
.track_scroll(&self.scroll_handle)
|
||||
.children(self.children),
|
||||
)
|
||||
.when_some(self.suffix, |this, suffix| this.child(suffix))
|
||||
}
|
||||
}
|
||||
1226
crates/ui/src/table.rs
Normal file
1226
crates/ui/src/table.rs
Normal file
File diff suppressed because it is too large
Load Diff
537
crates/ui/src/theme.rs
Normal file
537
crates/ui/src/theme.rs
Normal file
@@ -0,0 +1,537 @@
|
||||
use std::ops::{Deref, DerefMut};
|
||||
|
||||
use gpui::{
|
||||
hsla, point, AppContext, BoxShadow, Global, Hsla, ModelContext, Pixels, SharedString,
|
||||
ViewContext, WindowAppearance, WindowContext,
|
||||
};
|
||||
|
||||
pub fn init(cx: &mut AppContext) {
|
||||
Theme::sync_system_appearance(cx)
|
||||
}
|
||||
|
||||
pub trait ActiveTheme {
|
||||
fn theme(&self) -> &Theme;
|
||||
}
|
||||
|
||||
impl ActiveTheme for AppContext {
|
||||
fn theme(&self) -> &Theme {
|
||||
Theme::global(self)
|
||||
}
|
||||
}
|
||||
|
||||
impl<V> ActiveTheme for ViewContext<'_, V> {
|
||||
fn theme(&self) -> &Theme {
|
||||
self.deref().theme()
|
||||
}
|
||||
}
|
||||
|
||||
impl<V> ActiveTheme for ModelContext<'_, V> {
|
||||
fn theme(&self) -> &Theme {
|
||||
self.deref().theme()
|
||||
}
|
||||
}
|
||||
|
||||
impl ActiveTheme for WindowContext<'_> {
|
||||
fn theme(&self) -> &Theme {
|
||||
self.deref().theme()
|
||||
}
|
||||
}
|
||||
|
||||
/// Make a [gpui::Hsla] color.
|
||||
///
|
||||
/// - h: 0..360.0
|
||||
/// - s: 0.0..100.0
|
||||
/// - l: 0.0..100.0
|
||||
pub fn hsl(h: f32, s: f32, l: f32) -> Hsla {
|
||||
hsla(h / 360., s / 100.0, l / 100.0, 1.0)
|
||||
}
|
||||
|
||||
/// Make a BoxShadow like CSS
|
||||
///
|
||||
/// e.g:
|
||||
///
|
||||
/// If CSS is `box-shadow: 0 0 10px 0 rgba(0, 0, 0, 0.1);`
|
||||
///
|
||||
/// Then the equivalent in Rust is `box_shadow(0., 0., 10., 0., hsla(0., 0., 0., 0.1))`
|
||||
pub fn box_shadow(
|
||||
x: impl Into<Pixels>,
|
||||
y: impl Into<Pixels>,
|
||||
blur: impl Into<Pixels>,
|
||||
spread: impl Into<Pixels>,
|
||||
color: Hsla,
|
||||
) -> BoxShadow {
|
||||
BoxShadow {
|
||||
offset: point(x.into(), y.into()),
|
||||
blur_radius: blur.into(),
|
||||
spread_radius: spread.into(),
|
||||
color,
|
||||
}
|
||||
}
|
||||
pub trait Colorize {
|
||||
fn opacity(&self, opacity: f32) -> Hsla;
|
||||
fn divide(&self, divisor: f32) -> Hsla;
|
||||
fn invert(&self) -> Hsla;
|
||||
fn invert_l(&self) -> Hsla;
|
||||
fn lighten(&self, amount: f32) -> Hsla;
|
||||
fn darken(&self, amount: f32) -> Hsla;
|
||||
fn apply(&self, base_color: Hsla) -> Hsla;
|
||||
}
|
||||
|
||||
impl Colorize for Hsla {
|
||||
/// Returns a new color with the given opacity.
|
||||
///
|
||||
/// The opacity is a value between 0.0 and 1.0, where 0.0 is fully transparent and 1.0 is fully opaque.
|
||||
fn opacity(&self, factor: f32) -> Hsla {
|
||||
Hsla {
|
||||
a: self.a * factor.clamp(0.0, 1.0),
|
||||
..*self
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns a new color with each channel divided by the given divisor.
|
||||
///
|
||||
/// The divisor in range of 0.0 .. 1.0
|
||||
fn divide(&self, divisor: f32) -> Hsla {
|
||||
Hsla {
|
||||
a: divisor,
|
||||
..*self
|
||||
}
|
||||
}
|
||||
|
||||
/// Return inverted color
|
||||
fn invert(&self) -> Hsla {
|
||||
Hsla {
|
||||
h: (self.h + 1.8) % 3.6,
|
||||
s: 1.0 - self.s,
|
||||
l: 1.0 - self.l,
|
||||
a: self.a,
|
||||
}
|
||||
}
|
||||
|
||||
/// Return inverted lightness
|
||||
fn invert_l(&self) -> Hsla {
|
||||
Hsla {
|
||||
l: 1.0 - self.l,
|
||||
..*self
|
||||
}
|
||||
}
|
||||
|
||||
/// Return a new color with the lightness increased by the given factor.
|
||||
fn lighten(&self, factor: f32) -> Hsla {
|
||||
let l = self.l + (1.0 - self.l) * factor.clamp(0.0, 1.0).min(1.0);
|
||||
|
||||
Hsla { l, ..*self }
|
||||
}
|
||||
|
||||
/// Return a new color with the darkness increased by the given factor.
|
||||
fn darken(&self, factor: f32) -> Hsla {
|
||||
let l = self.l * (1.0 - factor.clamp(0.0, 1.0).min(1.0));
|
||||
|
||||
Hsla { l, ..*self }
|
||||
}
|
||||
|
||||
/// Return a new color with the same lightness and alpha but different hue and saturation.
|
||||
fn apply(&self, new_color: Hsla) -> Hsla {
|
||||
Hsla {
|
||||
h: new_color.h,
|
||||
s: new_color.s,
|
||||
l: self.l,
|
||||
a: self.a,
|
||||
}
|
||||
}
|
||||
}
|
||||
#[derive(Debug, Clone, Copy, Default)]
|
||||
pub struct ThemeColor {
|
||||
pub accent: Hsla,
|
||||
pub accent_foreground: Hsla,
|
||||
pub accordion: Hsla,
|
||||
pub accordion_active: Hsla,
|
||||
pub accordion_hover: Hsla,
|
||||
pub background: Hsla,
|
||||
pub border: Hsla,
|
||||
pub card: Hsla,
|
||||
pub card_foreground: Hsla,
|
||||
pub destructive: Hsla,
|
||||
pub destructive_active: Hsla,
|
||||
pub destructive_foreground: Hsla,
|
||||
pub destructive_hover: Hsla,
|
||||
pub drag_border: Hsla,
|
||||
pub drop_target: Hsla,
|
||||
pub foreground: Hsla,
|
||||
pub input: Hsla,
|
||||
pub link: Hsla,
|
||||
pub link_active: Hsla,
|
||||
pub link_hover: Hsla,
|
||||
pub list: Hsla,
|
||||
pub list_active: Hsla,
|
||||
pub list_active_border: Hsla,
|
||||
pub list_even: Hsla,
|
||||
pub list_head: Hsla,
|
||||
pub list_hover: Hsla,
|
||||
pub muted: Hsla,
|
||||
pub muted_foreground: Hsla,
|
||||
pub panel: Hsla,
|
||||
pub popover: Hsla,
|
||||
pub popover_foreground: Hsla,
|
||||
pub primary: Hsla,
|
||||
pub primary_active: Hsla,
|
||||
pub primary_foreground: Hsla,
|
||||
pub primary_hover: Hsla,
|
||||
pub progress_bar: Hsla,
|
||||
pub ring: Hsla,
|
||||
pub scrollbar: Hsla,
|
||||
pub scrollbar_thumb: Hsla,
|
||||
pub secondary: Hsla,
|
||||
pub secondary_active: Hsla,
|
||||
pub secondary_foreground: Hsla,
|
||||
pub secondary_hover: Hsla,
|
||||
pub selection: Hsla,
|
||||
pub skeleton: Hsla,
|
||||
pub slider_bar: Hsla,
|
||||
pub slider_thumb: Hsla,
|
||||
pub tab: Hsla,
|
||||
pub tab_active: Hsla,
|
||||
pub tab_active_foreground: Hsla,
|
||||
pub tab_bar: Hsla,
|
||||
pub tab_foreground: Hsla,
|
||||
pub table: Hsla,
|
||||
pub table_active: Hsla,
|
||||
pub table_active_border: Hsla,
|
||||
pub table_even: Hsla,
|
||||
pub table_head: Hsla,
|
||||
pub table_head_foreground: Hsla,
|
||||
pub table_hover: Hsla,
|
||||
pub table_row_border: Hsla,
|
||||
pub title_bar: Hsla,
|
||||
pub title_bar_border: Hsla,
|
||||
pub sidebar: Hsla,
|
||||
pub sidebar_accent: Hsla,
|
||||
pub sidebar_accent_foreground: Hsla,
|
||||
pub sidebar_border: Hsla,
|
||||
pub sidebar_foreground: Hsla,
|
||||
pub sidebar_primary: Hsla,
|
||||
pub sidebar_primary_foreground: Hsla,
|
||||
}
|
||||
|
||||
impl ThemeColor {
|
||||
pub fn light() -> Self {
|
||||
Self {
|
||||
accent: hsl(240.0, 5.0, 96.0),
|
||||
accent_foreground: hsl(240.0, 5.9, 10.0),
|
||||
accordion: hsl(0.0, 0.0, 100.0),
|
||||
accordion_active: hsl(240.0, 5.9, 90.0),
|
||||
accordion_hover: hsl(240.0, 4.8, 95.9).opacity(0.7),
|
||||
background: hsl(0.0, 0.0, 100.),
|
||||
border: hsl(240.0, 5.9, 90.0),
|
||||
card: hsl(0.0, 0.0, 100.0),
|
||||
card_foreground: hsl(240.0, 10.0, 3.9),
|
||||
destructive: hsl(0.0, 84.2, 60.2),
|
||||
destructive_active: hsl(0.0, 84.2, 47.0),
|
||||
destructive_foreground: hsl(0.0, 0.0, 98.0),
|
||||
destructive_hover: hsl(0.0, 84.2, 65.0),
|
||||
drag_border: crate::blue_500(),
|
||||
drop_target: hsl(235.0, 30., 44.0).opacity(0.25),
|
||||
foreground: hsl(240.0, 10., 3.9),
|
||||
input: hsl(240.0, 5.9, 90.0),
|
||||
link: hsl(221.0, 83.0, 53.0),
|
||||
link_active: hsl(221.0, 83.0, 53.0).darken(0.2),
|
||||
link_hover: hsl(221.0, 83.0, 53.0).lighten(0.2),
|
||||
list: hsl(0.0, 0.0, 100.),
|
||||
list_active: hsl(211.0, 97.0, 85.0).opacity(0.2),
|
||||
list_active_border: hsl(211.0, 97.0, 85.0),
|
||||
list_even: hsl(240.0, 5.0, 96.0),
|
||||
list_head: hsl(0.0, 0.0, 100.),
|
||||
list_hover: hsl(240.0, 4.8, 95.0),
|
||||
muted: hsl(240.0, 4.8, 95.9),
|
||||
muted_foreground: hsl(240.0, 3.8, 46.1),
|
||||
panel: hsl(0.0, 0.0, 100.0),
|
||||
popover: hsl(0.0, 0.0, 100.0),
|
||||
popover_foreground: hsl(240.0, 10.0, 3.9),
|
||||
primary: hsl(223.0, 5.9, 10.0),
|
||||
primary_active: hsl(223.0, 1.9, 25.0),
|
||||
primary_foreground: hsl(223.0, 0.0, 98.0),
|
||||
primary_hover: hsl(223.0, 5.9, 15.0),
|
||||
progress_bar: hsl(223.0, 5.9, 10.0),
|
||||
ring: hsl(240.0, 5.9, 65.0),
|
||||
scrollbar: hsl(0., 0., 97.).opacity(0.3),
|
||||
scrollbar_thumb: hsl(0., 0., 69.),
|
||||
secondary: hsl(240.0, 5.9, 96.9),
|
||||
secondary_active: hsl(240.0, 5.9, 93.),
|
||||
secondary_foreground: hsl(240.0, 59.0, 10.),
|
||||
secondary_hover: hsl(240.0, 5.9, 98.),
|
||||
selection: hsl(211.0, 97.0, 85.0),
|
||||
skeleton: hsl(223.0, 5.9, 10.0).opacity(0.1),
|
||||
slider_bar: hsl(223.0, 5.9, 10.0),
|
||||
slider_thumb: hsl(0.0, 0.0, 100.0),
|
||||
tab: gpui::transparent_black(),
|
||||
tab_active: hsl(0.0, 0.0, 100.0),
|
||||
tab_active_foreground: hsl(240.0, 10., 3.9),
|
||||
tab_bar: hsl(240.0, 4.8, 95.9),
|
||||
tab_foreground: hsl(240.0, 10., 3.9),
|
||||
table: hsl(0.0, 0.0, 100.),
|
||||
table_active: hsl(211.0, 97.0, 85.0).opacity(0.2),
|
||||
table_active_border: hsl(211.0, 97.0, 85.0),
|
||||
table_even: hsl(240.0, 5.0, 96.0),
|
||||
table_head: hsl(0.0, 0.0, 100.),
|
||||
table_head_foreground: hsl(240.0, 10., 3.9).opacity(0.7),
|
||||
table_hover: hsl(240.0, 4.8, 95.0),
|
||||
table_row_border: hsl(240.0, 7.7, 94.5),
|
||||
title_bar: hsl(0.0, 0.0, 100.),
|
||||
title_bar_border: hsl(240.0, 5.9, 90.0),
|
||||
sidebar: hsl(0.0, 0.0, 98.0),
|
||||
sidebar_accent: hsl(240.0, 4.8, 92.),
|
||||
sidebar_accent_foreground: hsl(240.0, 5.9, 10.0),
|
||||
sidebar_border: hsl(220.0, 13.0, 91.0),
|
||||
sidebar_foreground: hsl(240.0, 5.3, 26.1),
|
||||
sidebar_primary: hsl(240.0, 5.9, 10.0),
|
||||
sidebar_primary_foreground: hsl(0.0, 0.0, 98.0),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn dark() -> Self {
|
||||
Self {
|
||||
accent: hsl(240.0, 3.7, 15.9),
|
||||
accent_foreground: hsl(0.0, 0.0, 78.0),
|
||||
accordion: hsl(299.0, 2., 11.),
|
||||
accordion_active: hsl(240.0, 3.7, 16.9),
|
||||
accordion_hover: hsl(240.0, 3.7, 15.9).opacity(0.7),
|
||||
background: hsl(0.0, 0.0, 8.0),
|
||||
border: hsl(240.0, 3.7, 16.9),
|
||||
card: hsl(0.0, 0.0, 8.0),
|
||||
card_foreground: hsl(0.0, 0.0, 78.0),
|
||||
destructive: hsl(0.0, 62.8, 30.6),
|
||||
destructive_active: hsl(0.0, 62.8, 20.6),
|
||||
destructive_foreground: hsl(0.0, 0.0, 78.0),
|
||||
destructive_hover: hsl(0.0, 62.8, 35.6),
|
||||
drag_border: crate::blue_500(),
|
||||
drop_target: hsl(235.0, 30., 44.0).opacity(0.1),
|
||||
foreground: hsl(0., 0., 78.),
|
||||
input: hsl(240.0, 3.7, 15.9),
|
||||
link: hsl(221.0, 83.0, 53.0),
|
||||
link_active: hsl(221.0, 83.0, 53.0).darken(0.2),
|
||||
link_hover: hsl(221.0, 83.0, 53.0).lighten(0.2),
|
||||
list: hsl(0.0, 0.0, 8.0),
|
||||
list_active: hsl(240.0, 3.7, 15.0).opacity(0.2),
|
||||
list_active_border: hsl(240.0, 5.9, 35.5),
|
||||
list_even: hsl(240.0, 3.7, 10.0),
|
||||
list_head: hsl(0.0, 0.0, 8.0),
|
||||
list_hover: hsl(240.0, 3.7, 15.9),
|
||||
muted: hsl(240.0, 3.7, 15.9),
|
||||
muted_foreground: hsl(240.0, 5.0, 64.9),
|
||||
panel: hsl(299.0, 2., 11.),
|
||||
popover: hsl(0.0, 0.0, 10.),
|
||||
popover_foreground: hsl(0.0, 0.0, 78.0),
|
||||
primary: hsl(223.0, 0.0, 98.0),
|
||||
primary_active: hsl(223.0, 0.0, 80.0),
|
||||
primary_foreground: hsl(223.0, 5.9, 10.0),
|
||||
primary_hover: hsl(223.0, 0.0, 90.0),
|
||||
progress_bar: hsl(223.0, 0.0, 98.0),
|
||||
ring: hsl(240.0, 4.9, 83.9),
|
||||
scrollbar: hsl(240., 1., 15.).opacity(0.3),
|
||||
scrollbar_thumb: hsl(0., 0., 68.),
|
||||
secondary: hsl(240.0, 0., 13.0),
|
||||
secondary_active: hsl(240.0, 0., 10.),
|
||||
secondary_foreground: hsl(0.0, 0.0, 78.0),
|
||||
secondary_hover: hsl(240.0, 0., 15.),
|
||||
selection: hsl(211.0, 97.0, 22.0),
|
||||
skeleton: hsla(223.0, 0.0, 98.0, 0.1),
|
||||
slider_bar: hsl(223.0, 0.0, 98.0),
|
||||
slider_thumb: hsl(0.0, 0.0, 8.0),
|
||||
tab: gpui::transparent_black(),
|
||||
tab_active: hsl(0.0, 0.0, 8.0),
|
||||
tab_active_foreground: hsl(0., 0., 78.),
|
||||
tab_bar: hsl(299.0, 0., 5.5),
|
||||
tab_foreground: hsl(0., 0., 78.),
|
||||
table: hsl(0.0, 0.0, 8.0),
|
||||
table_active: hsl(240.0, 3.7, 15.0).opacity(0.2),
|
||||
table_active_border: hsl(240.0, 5.9, 35.5),
|
||||
table_even: hsl(240.0, 3.7, 10.0),
|
||||
table_head: hsl(0.0, 0.0, 8.0),
|
||||
table_head_foreground: hsl(0., 0., 78.).opacity(0.7),
|
||||
table_hover: hsl(240.0, 3.7, 15.9).opacity(0.5),
|
||||
table_row_border: hsl(240.0, 3.7, 16.9).opacity(0.5),
|
||||
title_bar: hsl(0., 0., 9.7),
|
||||
title_bar_border: hsl(240.0, 3.7, 15.9),
|
||||
sidebar: hsl(240.0, 0.0, 10.0),
|
||||
sidebar_accent: hsl(240.0, 3.7, 15.9),
|
||||
sidebar_accent_foreground: hsl(240.0, 4.8, 95.9),
|
||||
sidebar_border: hsl(240.0, 3.7, 15.9),
|
||||
sidebar_foreground: hsl(240.0, 4.8, 95.9),
|
||||
sidebar_primary: hsl(0.0, 0.0, 98.0),
|
||||
sidebar_primary_foreground: hsl(240.0, 5.9, 10.0),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Theme {
|
||||
colors: ThemeColor,
|
||||
|
||||
pub mode: ThemeMode,
|
||||
pub font_family: SharedString,
|
||||
pub font_size: f32,
|
||||
pub radius: f32,
|
||||
pub shadow: bool,
|
||||
pub transparent: Hsla,
|
||||
}
|
||||
|
||||
impl Deref for Theme {
|
||||
type Target = ThemeColor;
|
||||
|
||||
fn deref(&self) -> &Self::Target {
|
||||
&self.colors
|
||||
}
|
||||
}
|
||||
|
||||
impl DerefMut for Theme {
|
||||
fn deref_mut(&mut self) -> &mut Self::Target {
|
||||
&mut self.colors
|
||||
}
|
||||
}
|
||||
|
||||
impl Global for Theme {}
|
||||
|
||||
impl Theme {
|
||||
/// Returns the global theme reference
|
||||
pub fn global(cx: &AppContext) -> &Theme {
|
||||
cx.global::<Theme>()
|
||||
}
|
||||
|
||||
/// Returns the global theme mutable reference
|
||||
pub fn global_mut(cx: &mut AppContext) -> &mut Theme {
|
||||
cx.global_mut::<Theme>()
|
||||
}
|
||||
|
||||
/// Apply a mask color to the theme.
|
||||
pub fn apply_color(&mut self, mask_color: Hsla) {
|
||||
self.title_bar = self.title_bar.apply(mask_color);
|
||||
self.title_bar_border = self.title_bar_border.apply(mask_color);
|
||||
self.background = self.background.apply(mask_color);
|
||||
self.foreground = self.foreground.apply(mask_color);
|
||||
self.card = self.card.apply(mask_color);
|
||||
self.card_foreground = self.card_foreground.apply(mask_color);
|
||||
self.popover = self.popover.apply(mask_color);
|
||||
self.popover_foreground = self.popover_foreground.apply(mask_color);
|
||||
self.primary = self.primary.apply(mask_color);
|
||||
self.primary_hover = self.primary_hover.apply(mask_color);
|
||||
self.primary_active = self.primary_active.apply(mask_color);
|
||||
self.primary_foreground = self.primary_foreground.apply(mask_color);
|
||||
self.secondary = self.secondary.apply(mask_color);
|
||||
self.secondary_hover = self.secondary_hover.apply(mask_color);
|
||||
self.secondary_active = self.secondary_active.apply(mask_color);
|
||||
self.secondary_foreground = self.secondary_foreground.apply(mask_color);
|
||||
// self.destructive = self.destructive.apply(mask_color);
|
||||
// self.destructive_hover = self.destructive_hover.apply(mask_color);
|
||||
// self.destructive_active = self.destructive_active.apply(mask_color);
|
||||
// self.destructive_foreground = self.destructive_foreground.apply(mask_color);
|
||||
self.muted = self.muted.apply(mask_color);
|
||||
self.muted_foreground = self.muted_foreground.apply(mask_color);
|
||||
self.accent = self.accent.apply(mask_color);
|
||||
self.accent_foreground = self.accent_foreground.apply(mask_color);
|
||||
self.border = self.border.apply(mask_color);
|
||||
self.input = self.input.apply(mask_color);
|
||||
self.ring = self.ring.apply(mask_color);
|
||||
// self.selection = self.selection.apply(mask_color);
|
||||
self.scrollbar = self.scrollbar.apply(mask_color);
|
||||
self.scrollbar_thumb = self.scrollbar_thumb.apply(mask_color);
|
||||
self.panel = self.panel.apply(mask_color);
|
||||
self.drag_border = self.drag_border.apply(mask_color);
|
||||
self.drop_target = self.drop_target.apply(mask_color);
|
||||
self.tab_bar = self.tab_bar.apply(mask_color);
|
||||
self.tab = self.tab.apply(mask_color);
|
||||
self.tab_active = self.tab_active.apply(mask_color);
|
||||
self.tab_foreground = self.tab_foreground.apply(mask_color);
|
||||
self.tab_active_foreground = self.tab_active_foreground.apply(mask_color);
|
||||
self.progress_bar = self.progress_bar.apply(mask_color);
|
||||
self.slider_bar = self.slider_bar.apply(mask_color);
|
||||
self.slider_thumb = self.slider_thumb.apply(mask_color);
|
||||
self.list = self.list.apply(mask_color);
|
||||
self.list_even = self.list_even.apply(mask_color);
|
||||
self.list_head = self.list_head.apply(mask_color);
|
||||
self.list_active = self.list_active.apply(mask_color);
|
||||
self.list_active_border = self.list_active_border.apply(mask_color);
|
||||
self.list_hover = self.list_hover.apply(mask_color);
|
||||
self.table = self.table.apply(mask_color);
|
||||
self.table_even = self.table_even.apply(mask_color);
|
||||
self.table_active = self.table_active.apply(mask_color);
|
||||
self.table_active_border = self.table_active_border.apply(mask_color);
|
||||
self.table_hover = self.table_hover.apply(mask_color);
|
||||
self.table_row_border = self.table_row_border.apply(mask_color);
|
||||
self.table_head = self.table_head.apply(mask_color);
|
||||
self.table_head_foreground = self.table_head_foreground.apply(mask_color);
|
||||
self.link = self.link.apply(mask_color);
|
||||
self.link_hover = self.link_hover.apply(mask_color);
|
||||
self.link_active = self.link_active.apply(mask_color);
|
||||
self.skeleton = self.skeleton.apply(mask_color);
|
||||
self.accordion = self.accordion.apply(mask_color);
|
||||
self.accordion_hover = self.accordion_hover.apply(mask_color);
|
||||
self.accordion_active = self.accordion_active.apply(mask_color);
|
||||
self.title_bar = self.title_bar.apply(mask_color);
|
||||
self.title_bar_border = self.title_bar_border.apply(mask_color);
|
||||
self.sidebar = self.sidebar.apply(mask_color);
|
||||
self.sidebar_accent = self.sidebar_accent.apply(mask_color);
|
||||
self.sidebar_accent_foreground = self.sidebar_accent_foreground.apply(mask_color);
|
||||
self.sidebar_border = self.sidebar_border.apply(mask_color);
|
||||
self.sidebar_foreground = self.sidebar_foreground.apply(mask_color);
|
||||
self.sidebar_primary = self.sidebar_primary.apply(mask_color);
|
||||
self.sidebar_primary_foreground = self.sidebar_primary_foreground.apply(mask_color);
|
||||
}
|
||||
|
||||
/// Sync the theme with the system appearance
|
||||
pub fn sync_system_appearance(cx: &mut AppContext) {
|
||||
match cx.window_appearance() {
|
||||
WindowAppearance::Dark | WindowAppearance::VibrantDark => {
|
||||
Self::change(ThemeMode::Dark, cx)
|
||||
}
|
||||
WindowAppearance::Light | WindowAppearance::VibrantLight => {
|
||||
Self::change(ThemeMode::Light, cx)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn change(mode: ThemeMode, cx: &mut AppContext) {
|
||||
let colors = match mode {
|
||||
ThemeMode::Light => ThemeColor::light(),
|
||||
ThemeMode::Dark => ThemeColor::dark(),
|
||||
};
|
||||
|
||||
let mut theme = Theme::from(colors);
|
||||
theme.mode = mode;
|
||||
|
||||
cx.set_global(theme);
|
||||
cx.refresh();
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ThemeColor> for Theme {
|
||||
fn from(colors: ThemeColor) -> Self {
|
||||
Theme {
|
||||
mode: ThemeMode::default(),
|
||||
transparent: Hsla::transparent_black(),
|
||||
font_size: 16.0,
|
||||
font_family: if cfg!(target_os = "macos") {
|
||||
".SystemUIFont".into()
|
||||
} else if cfg!(target_os = "windows") {
|
||||
"Segoe UI".into()
|
||||
} else {
|
||||
"FreeMono".into()
|
||||
},
|
||||
radius: 4.0,
|
||||
shadow: true,
|
||||
colors,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, PartialOrd, Eq)]
|
||||
pub enum ThemeMode {
|
||||
Light,
|
||||
#[default]
|
||||
Dark,
|
||||
}
|
||||
|
||||
impl ThemeMode {
|
||||
pub fn is_dark(&self) -> bool {
|
||||
matches!(self, Self::Dark)
|
||||
}
|
||||
}
|
||||
330
crates/ui/src/title_bar.rs
Normal file
330
crates/ui/src/title_bar.rs
Normal file
@@ -0,0 +1,330 @@
|
||||
use std::rc::Rc;
|
||||
|
||||
use crate::{h_flex, theme::ActiveTheme, Icon, IconName, InteractiveElementExt as _, Sizable as _};
|
||||
use gpui::{
|
||||
div, prelude::FluentBuilder as _, px, relative, AnyElement, ClickEvent, Div, Element, Hsla,
|
||||
InteractiveElement as _, IntoElement, ParentElement, Pixels, RenderOnce, Stateful,
|
||||
StatefulInteractiveElement as _, Style, Styled, WindowContext,
|
||||
};
|
||||
|
||||
pub const TITLE_BAR_HEIGHT: Pixels = px(35.);
|
||||
#[cfg(target_os = "macos")]
|
||||
const TITLE_BAR_LEFT_PADDING: Pixels = px(80.);
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
const TITLE_BAR_LEFT_PADDING: Pixels = px(12.);
|
||||
|
||||
/// TitleBar used to customize the appearance of the title bar.
|
||||
///
|
||||
/// We can put some elements inside the title bar.
|
||||
#[derive(IntoElement)]
|
||||
pub struct TitleBar {
|
||||
base: Stateful<Div>,
|
||||
children: Vec<AnyElement>,
|
||||
on_close_window: Option<Rc<Box<dyn Fn(&ClickEvent, &mut WindowContext)>>>,
|
||||
}
|
||||
|
||||
impl TitleBar {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
base: div().id("title-bar").pl(TITLE_BAR_LEFT_PADDING),
|
||||
children: Vec::new(),
|
||||
on_close_window: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Add custom for close window event, default is None, then click X button will call `cx.remove_window()`.
|
||||
/// Linux only, this will do nothing on other platforms.
|
||||
pub fn on_close_window(
|
||||
mut self,
|
||||
f: impl Fn(&ClickEvent, &mut WindowContext) + 'static,
|
||||
) -> Self {
|
||||
if cfg!(target_os = "linux") {
|
||||
self.on_close_window = Some(Rc::new(Box::new(f)));
|
||||
}
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
// The Windows control buttons have a fixed width of 35px.
|
||||
//
|
||||
// We don't need implementation the click event for the control buttons.
|
||||
// If user clicked in the bounds, the window event will be triggered.
|
||||
#[derive(IntoElement, Clone)]
|
||||
enum ControlIcon {
|
||||
Minimize,
|
||||
Restore,
|
||||
Maximize,
|
||||
Close {
|
||||
on_close_window: Option<Rc<Box<dyn Fn(&ClickEvent, &mut WindowContext)>>>,
|
||||
},
|
||||
}
|
||||
|
||||
impl ControlIcon {
|
||||
fn minimize() -> Self {
|
||||
Self::Minimize
|
||||
}
|
||||
|
||||
fn restore() -> Self {
|
||||
Self::Restore
|
||||
}
|
||||
|
||||
fn maximize() -> Self {
|
||||
Self::Maximize
|
||||
}
|
||||
|
||||
fn close(on_close_window: Option<Rc<Box<dyn Fn(&ClickEvent, &mut WindowContext)>>>) -> Self {
|
||||
Self::Close { on_close_window }
|
||||
}
|
||||
|
||||
fn id(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Minimize => "minimize",
|
||||
Self::Restore => "restore",
|
||||
Self::Maximize => "maximize",
|
||||
Self::Close { .. } => "close",
|
||||
}
|
||||
}
|
||||
|
||||
fn icon(&self) -> IconName {
|
||||
match self {
|
||||
Self::Minimize => IconName::WindowMinimize,
|
||||
Self::Restore => IconName::WindowRestore,
|
||||
Self::Maximize => IconName::WindowMaximize,
|
||||
Self::Close { .. } => IconName::WindowClose,
|
||||
}
|
||||
}
|
||||
|
||||
fn is_close(&self) -> bool {
|
||||
matches!(self, Self::Close { .. })
|
||||
}
|
||||
|
||||
fn fg(&self, cx: &WindowContext) -> Hsla {
|
||||
if cx.theme().mode.is_dark() {
|
||||
crate::white()
|
||||
} else {
|
||||
crate::black()
|
||||
}
|
||||
}
|
||||
|
||||
fn hover_fg(&self, cx: &WindowContext) -> Hsla {
|
||||
if self.is_close() || cx.theme().mode.is_dark() {
|
||||
crate::white()
|
||||
} else {
|
||||
crate::black()
|
||||
}
|
||||
}
|
||||
|
||||
fn hover_bg(&self, cx: &WindowContext) -> Hsla {
|
||||
if self.is_close() {
|
||||
if cx.theme().mode.is_dark() {
|
||||
crate::red_800()
|
||||
} else {
|
||||
crate::red_600()
|
||||
}
|
||||
} else if cx.theme().mode.is_dark() {
|
||||
crate::stone_700()
|
||||
} else {
|
||||
crate::stone_200()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for ControlIcon {
|
||||
fn render(self, cx: &mut WindowContext) -> impl IntoElement {
|
||||
let fg = self.fg(cx);
|
||||
let hover_fg = self.hover_fg(cx);
|
||||
let hover_bg = self.hover_bg(cx);
|
||||
let icon = self.clone();
|
||||
let is_linux = cfg!(target_os = "linux");
|
||||
let on_close_window = match &icon {
|
||||
ControlIcon::Close { on_close_window } => on_close_window.clone(),
|
||||
_ => None,
|
||||
};
|
||||
|
||||
div()
|
||||
.id(self.id())
|
||||
.flex()
|
||||
.cursor_pointer()
|
||||
.w(TITLE_BAR_HEIGHT)
|
||||
.h_full()
|
||||
.justify_center()
|
||||
.content_center()
|
||||
.items_center()
|
||||
.text_color(fg)
|
||||
.when(is_linux, |this| {
|
||||
this.on_click(move |_, cx| match icon {
|
||||
Self::Minimize => cx.minimize_window(),
|
||||
Self::Restore => cx.zoom_window(),
|
||||
Self::Maximize => cx.zoom_window(),
|
||||
Self::Close { .. } => {
|
||||
if let Some(f) = on_close_window.clone() {
|
||||
f(&ClickEvent::default(), cx);
|
||||
} else {
|
||||
cx.remove_window();
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
.hover(|style| style.bg(hover_bg).text_color(hover_fg))
|
||||
.active(|style| style.bg(hover_bg.opacity(0.7)))
|
||||
.child(Icon::new(self.icon()).small())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(IntoElement)]
|
||||
struct WindowControls {
|
||||
on_close_window: Option<Rc<Box<dyn Fn(&ClickEvent, &mut WindowContext)>>>,
|
||||
}
|
||||
|
||||
impl RenderOnce for WindowControls {
|
||||
fn render(self, cx: &mut WindowContext) -> impl IntoElement {
|
||||
if cfg!(target_os = "macos") {
|
||||
return div().id("window-controls");
|
||||
}
|
||||
|
||||
h_flex()
|
||||
.id("window-controls")
|
||||
.items_center()
|
||||
.flex_shrink_0()
|
||||
.h_full()
|
||||
.child(
|
||||
h_flex()
|
||||
.justify_center()
|
||||
.content_stretch()
|
||||
.h_full()
|
||||
.child(ControlIcon::minimize())
|
||||
.child(if cx.is_maximized() {
|
||||
ControlIcon::restore()
|
||||
} else {
|
||||
ControlIcon::maximize()
|
||||
}),
|
||||
)
|
||||
.child(ControlIcon::close(self.on_close_window))
|
||||
}
|
||||
}
|
||||
|
||||
impl Styled for TitleBar {
|
||||
fn style(&mut self) -> &mut gpui::StyleRefinement {
|
||||
self.base.style()
|
||||
}
|
||||
}
|
||||
|
||||
impl ParentElement for TitleBar {
|
||||
fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
|
||||
self.children.extend(elements);
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for TitleBar {
|
||||
fn render(self, cx: &mut WindowContext) -> impl IntoElement {
|
||||
let is_linux = cfg!(target_os = "linux");
|
||||
|
||||
const HEIGHT: Pixels = px(34.);
|
||||
|
||||
div()
|
||||
.flex_shrink_0()
|
||||
.child(
|
||||
self.base
|
||||
.flex()
|
||||
.flex_row()
|
||||
.items_center()
|
||||
.justify_between()
|
||||
.h(HEIGHT)
|
||||
.border_b_1()
|
||||
.border_color(cx.theme().title_bar_border)
|
||||
.bg(cx.theme().title_bar)
|
||||
.when(cx.is_fullscreen(), |this| this.pl(px(12.)))
|
||||
.on_double_click(|_, cx| cx.zoom_window())
|
||||
.child(
|
||||
h_flex()
|
||||
.h_full()
|
||||
.justify_between()
|
||||
.flex_shrink_0()
|
||||
.flex_1()
|
||||
.children(self.children),
|
||||
)
|
||||
.child(WindowControls {
|
||||
on_close_window: self.on_close_window,
|
||||
}),
|
||||
)
|
||||
.when(is_linux, |this| {
|
||||
this.child(
|
||||
div()
|
||||
.top_0()
|
||||
.left_0()
|
||||
.absolute()
|
||||
.size_full()
|
||||
.h_full()
|
||||
.child(TitleBarElement {}),
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// A TitleBar Element that can be move the window.
|
||||
pub struct TitleBarElement {}
|
||||
|
||||
impl IntoElement for TitleBarElement {
|
||||
type Element = Self;
|
||||
|
||||
fn into_element(self) -> Self::Element {
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl Element for TitleBarElement {
|
||||
type RequestLayoutState = ();
|
||||
|
||||
type PrepaintState = ();
|
||||
|
||||
fn id(&self) -> Option<gpui::ElementId> {
|
||||
None
|
||||
}
|
||||
|
||||
fn request_layout(
|
||||
&mut self,
|
||||
_: Option<&gpui::GlobalElementId>,
|
||||
cx: &mut WindowContext,
|
||||
) -> (gpui::LayoutId, Self::RequestLayoutState) {
|
||||
let mut style = Style::default();
|
||||
style.flex_grow = 1.0;
|
||||
style.flex_shrink = 1.0;
|
||||
style.size.width = relative(1.).into();
|
||||
style.size.height = relative(1.).into();
|
||||
|
||||
let id = cx.request_layout(style, []);
|
||||
(id, ())
|
||||
}
|
||||
|
||||
fn prepaint(
|
||||
&mut self,
|
||||
_: Option<&gpui::GlobalElementId>,
|
||||
_: gpui::Bounds<Pixels>,
|
||||
_: &mut Self::RequestLayoutState,
|
||||
_: &mut WindowContext,
|
||||
) -> Self::PrepaintState {
|
||||
}
|
||||
|
||||
#[allow(unused_variables)]
|
||||
fn paint(
|
||||
&mut self,
|
||||
_: Option<&gpui::GlobalElementId>,
|
||||
bounds: gpui::Bounds<Pixels>,
|
||||
_: &mut Self::RequestLayoutState,
|
||||
_: &mut Self::PrepaintState,
|
||||
cx: &mut WindowContext,
|
||||
) {
|
||||
use gpui::{MouseButton, MouseMoveEvent, MouseUpEvent};
|
||||
cx.on_mouse_event(move |ev: &MouseMoveEvent, _, cx: &mut WindowContext| {
|
||||
if bounds.contains(&ev.position) && ev.pressed_button == Some(MouseButton::Left) {
|
||||
cx.start_window_move();
|
||||
}
|
||||
});
|
||||
|
||||
cx.on_mouse_event(move |ev: &MouseUpEvent, _, cx: &mut WindowContext| {
|
||||
if ev.button == MouseButton::Left {
|
||||
cx.show_window_menu(ev.position);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
38
crates/ui/src/tooltip.rs
Normal file
38
crates/ui/src/tooltip.rs
Normal file
@@ -0,0 +1,38 @@
|
||||
use gpui::{
|
||||
div, px, AnyView, IntoElement, ParentElement, Render, SharedString, Styled, ViewContext,
|
||||
VisualContext, WindowContext,
|
||||
};
|
||||
|
||||
use crate::theme::ActiveTheme;
|
||||
|
||||
pub struct Tooltip {
|
||||
text: SharedString,
|
||||
}
|
||||
|
||||
impl Tooltip {
|
||||
pub fn new(text: impl Into<SharedString>, cx: &mut WindowContext) -> AnyView {
|
||||
cx.new_view(|_| Self { text: text.into() }).into()
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for Tooltip {
|
||||
fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
|
||||
div().child(
|
||||
// Wrap in a child, to ensure the left margin is applied to the tooltip
|
||||
div()
|
||||
.font_family(".SystemUIFont")
|
||||
.m_3()
|
||||
.bg(cx.theme().popover)
|
||||
.text_color(cx.theme().popover_foreground)
|
||||
.bg(cx.theme().popover)
|
||||
.border_1()
|
||||
.border_color(cx.theme().border)
|
||||
.shadow_md()
|
||||
.rounded(px(6.))
|
||||
.py_0p5()
|
||||
.px_2()
|
||||
.text_sm()
|
||||
.child(self.text.clone()),
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user