1use std::sync::Arc;
2
3use crate::contact_finder;
4use call::ActiveCall;
5use client::{Contact, User, UserStore};
6use editor::{Cancel, Editor};
7use fuzzy::{match_strings, StringMatchCandidate};
8use gpui::{
9 elements::*, impl_actions, impl_internal_actions, keymap, AppContext, ClipboardItem,
10 CursorStyle, Entity, ModelHandle, MouseButton, MutableAppContext, RenderContext, Subscription,
11 View, ViewContext, ViewHandle,
12};
13use menu::{Confirm, SelectNext, SelectPrev};
14use project::Project;
15use serde::Deserialize;
16use settings::Settings;
17use theme::IconButton;
18
19impl_actions!(contacts_popover, [RemoveContact, RespondToContactRequest]);
20impl_internal_actions!(contacts_popover, [ToggleExpanded, Call]);
21
22pub fn init(cx: &mut MutableAppContext) {
23 cx.add_action(ContactsPopover::remove_contact);
24 cx.add_action(ContactsPopover::respond_to_contact_request);
25 cx.add_action(ContactsPopover::clear_filter);
26 cx.add_action(ContactsPopover::select_next);
27 cx.add_action(ContactsPopover::select_prev);
28 cx.add_action(ContactsPopover::confirm);
29 cx.add_action(ContactsPopover::toggle_expanded);
30 cx.add_action(ContactsPopover::call);
31}
32
33#[derive(Clone, PartialEq)]
34struct ToggleExpanded(Section);
35
36#[derive(Clone, PartialEq)]
37struct Call {
38 recipient_user_id: u64,
39 initial_project: Option<ModelHandle<Project>>,
40}
41
42#[derive(Clone, Copy, PartialEq, Eq, Debug, PartialOrd, Ord)]
43enum Section {
44 Requests,
45 Online,
46 Offline,
47}
48
49#[derive(Clone)]
50enum ContactEntry {
51 Header(Section),
52 IncomingRequest(Arc<User>),
53 OutgoingRequest(Arc<User>),
54 Contact(Arc<Contact>),
55}
56
57impl PartialEq for ContactEntry {
58 fn eq(&self, other: &Self) -> bool {
59 match self {
60 ContactEntry::Header(section_1) => {
61 if let ContactEntry::Header(section_2) = other {
62 return section_1 == section_2;
63 }
64 }
65 ContactEntry::IncomingRequest(user_1) => {
66 if let ContactEntry::IncomingRequest(user_2) = other {
67 return user_1.id == user_2.id;
68 }
69 }
70 ContactEntry::OutgoingRequest(user_1) => {
71 if let ContactEntry::OutgoingRequest(user_2) = other {
72 return user_1.id == user_2.id;
73 }
74 }
75 ContactEntry::Contact(contact_1) => {
76 if let ContactEntry::Contact(contact_2) = other {
77 return contact_1.user.id == contact_2.user.id;
78 }
79 }
80 }
81 false
82 }
83}
84
85#[derive(Clone, Deserialize, PartialEq)]
86pub struct RequestContact(pub u64);
87
88#[derive(Clone, Deserialize, PartialEq)]
89pub struct RemoveContact(pub u64);
90
91#[derive(Clone, Deserialize, PartialEq)]
92pub struct RespondToContactRequest {
93 pub user_id: u64,
94 pub accept: bool,
95}
96
97pub enum Event {
98 Dismissed,
99}
100
101pub struct ContactsPopover {
102 entries: Vec<ContactEntry>,
103 match_candidates: Vec<StringMatchCandidate>,
104 list_state: ListState,
105 project: ModelHandle<Project>,
106 user_store: ModelHandle<UserStore>,
107 filter_editor: ViewHandle<Editor>,
108 collapsed_sections: Vec<Section>,
109 selection: Option<usize>,
110 _subscriptions: Vec<Subscription>,
111}
112
113impl ContactsPopover {
114 pub fn new(
115 project: ModelHandle<Project>,
116 user_store: ModelHandle<UserStore>,
117 cx: &mut ViewContext<Self>,
118 ) -> Self {
119 let filter_editor = cx.add_view(|cx| {
120 let mut editor = Editor::single_line(
121 Some(|theme| theme.contacts_popover.user_query_editor.clone()),
122 cx,
123 );
124 editor.set_placeholder_text("Filter contacts", cx);
125 editor
126 });
127
128 cx.subscribe(&filter_editor, |this, _, event, cx| {
129 if let editor::Event::BufferEdited = event {
130 let query = this.filter_editor.read(cx).text(cx);
131 if !query.is_empty() {
132 this.selection.take();
133 }
134 this.update_entries(cx);
135 if !query.is_empty() {
136 this.selection = this
137 .entries
138 .iter()
139 .position(|entry| !matches!(entry, ContactEntry::Header(_)));
140 }
141 }
142 })
143 .detach();
144
145 let list_state = ListState::new(0, Orientation::Top, 1000., cx, move |this, ix, cx| {
146 let theme = cx.global::<Settings>().theme.clone();
147 let is_selected = this.selection == Some(ix);
148
149 match &this.entries[ix] {
150 ContactEntry::Header(section) => {
151 let is_collapsed = this.collapsed_sections.contains(section);
152 Self::render_header(
153 *section,
154 &theme.contacts_popover,
155 is_selected,
156 is_collapsed,
157 cx,
158 )
159 }
160 ContactEntry::IncomingRequest(user) => Self::render_contact_request(
161 user.clone(),
162 this.user_store.clone(),
163 &theme.contacts_popover,
164 true,
165 is_selected,
166 cx,
167 ),
168 ContactEntry::OutgoingRequest(user) => Self::render_contact_request(
169 user.clone(),
170 this.user_store.clone(),
171 &theme.contacts_popover,
172 false,
173 is_selected,
174 cx,
175 ),
176 ContactEntry::Contact(contact) => Self::render_contact(
177 contact,
178 &this.project,
179 &theme.contacts_popover,
180 is_selected,
181 cx,
182 ),
183 }
184 });
185
186 let active_call = ActiveCall::global(cx);
187 let mut subscriptions = Vec::new();
188 subscriptions.push(cx.observe(&user_store, |this, _, cx| this.update_entries(cx)));
189 subscriptions.push(cx.observe(&active_call, |_, _, cx| cx.notify()));
190
191 let mut this = Self {
192 list_state,
193 selection: None,
194 collapsed_sections: Default::default(),
195 entries: Default::default(),
196 match_candidates: Default::default(),
197 filter_editor,
198 _subscriptions: subscriptions,
199 project,
200 user_store,
201 };
202 this.update_entries(cx);
203 this
204 }
205
206 fn remove_contact(&mut self, request: &RemoveContact, cx: &mut ViewContext<Self>) {
207 self.user_store
208 .update(cx, |store, cx| store.remove_contact(request.0, cx))
209 .detach();
210 }
211
212 fn respond_to_contact_request(
213 &mut self,
214 action: &RespondToContactRequest,
215 cx: &mut ViewContext<Self>,
216 ) {
217 self.user_store
218 .update(cx, |store, cx| {
219 store.respond_to_contact_request(action.user_id, action.accept, cx)
220 })
221 .detach();
222 }
223
224 fn clear_filter(&mut self, _: &Cancel, cx: &mut ViewContext<Self>) {
225 let did_clear = self.filter_editor.update(cx, |editor, cx| {
226 if editor.buffer().read(cx).len(cx) > 0 {
227 editor.set_text("", cx);
228 true
229 } else {
230 false
231 }
232 });
233 if !did_clear {
234 cx.emit(Event::Dismissed);
235 }
236 }
237
238 fn select_next(&mut self, _: &SelectNext, cx: &mut ViewContext<Self>) {
239 if let Some(ix) = self.selection {
240 if self.entries.len() > ix + 1 {
241 self.selection = Some(ix + 1);
242 }
243 } else if !self.entries.is_empty() {
244 self.selection = Some(0);
245 }
246 cx.notify();
247 self.list_state.reset(self.entries.len());
248 }
249
250 fn select_prev(&mut self, _: &SelectPrev, cx: &mut ViewContext<Self>) {
251 if let Some(ix) = self.selection {
252 if ix > 0 {
253 self.selection = Some(ix - 1);
254 } else {
255 self.selection = None;
256 }
257 }
258 cx.notify();
259 self.list_state.reset(self.entries.len());
260 }
261
262 fn confirm(&mut self, _: &Confirm, cx: &mut ViewContext<Self>) {
263 if let Some(selection) = self.selection {
264 if let Some(entry) = self.entries.get(selection) {
265 match entry {
266 ContactEntry::Header(section) => {
267 let section = *section;
268 self.toggle_expanded(&ToggleExpanded(section), cx);
269 }
270 _ => {}
271 }
272 }
273 }
274 }
275
276 fn toggle_expanded(&mut self, action: &ToggleExpanded, cx: &mut ViewContext<Self>) {
277 let section = action.0;
278 if let Some(ix) = self.collapsed_sections.iter().position(|s| *s == section) {
279 self.collapsed_sections.remove(ix);
280 } else {
281 self.collapsed_sections.push(section);
282 }
283 self.update_entries(cx);
284 }
285
286 fn update_entries(&mut self, cx: &mut ViewContext<Self>) {
287 let user_store = self.user_store.read(cx);
288 let query = self.filter_editor.read(cx).text(cx);
289 let executor = cx.background().clone();
290
291 let prev_selected_entry = self.selection.and_then(|ix| self.entries.get(ix).cloned());
292 self.entries.clear();
293
294 let mut request_entries = Vec::new();
295 let incoming = user_store.incoming_contact_requests();
296 if !incoming.is_empty() {
297 self.match_candidates.clear();
298 self.match_candidates
299 .extend(
300 incoming
301 .iter()
302 .enumerate()
303 .map(|(ix, user)| StringMatchCandidate {
304 id: ix,
305 string: user.github_login.clone(),
306 char_bag: user.github_login.chars().collect(),
307 }),
308 );
309 let matches = executor.block(match_strings(
310 &self.match_candidates,
311 &query,
312 true,
313 usize::MAX,
314 &Default::default(),
315 executor.clone(),
316 ));
317 request_entries.extend(
318 matches
319 .iter()
320 .map(|mat| ContactEntry::IncomingRequest(incoming[mat.candidate_id].clone())),
321 );
322 }
323
324 let outgoing = user_store.outgoing_contact_requests();
325 if !outgoing.is_empty() {
326 self.match_candidates.clear();
327 self.match_candidates
328 .extend(
329 outgoing
330 .iter()
331 .enumerate()
332 .map(|(ix, user)| StringMatchCandidate {
333 id: ix,
334 string: user.github_login.clone(),
335 char_bag: user.github_login.chars().collect(),
336 }),
337 );
338 let matches = executor.block(match_strings(
339 &self.match_candidates,
340 &query,
341 true,
342 usize::MAX,
343 &Default::default(),
344 executor.clone(),
345 ));
346 request_entries.extend(
347 matches
348 .iter()
349 .map(|mat| ContactEntry::OutgoingRequest(outgoing[mat.candidate_id].clone())),
350 );
351 }
352
353 if !request_entries.is_empty() {
354 self.entries.push(ContactEntry::Header(Section::Requests));
355 if !self.collapsed_sections.contains(&Section::Requests) {
356 self.entries.append(&mut request_entries);
357 }
358 }
359
360 let contacts = user_store.contacts();
361 if !contacts.is_empty() {
362 // Always put the current user first.
363 self.match_candidates.clear();
364 self.match_candidates
365 .extend(
366 contacts
367 .iter()
368 .enumerate()
369 .map(|(ix, contact)| StringMatchCandidate {
370 id: ix,
371 string: contact.user.github_login.clone(),
372 char_bag: contact.user.github_login.chars().collect(),
373 }),
374 );
375
376 let matches = executor.block(match_strings(
377 &self.match_candidates,
378 &query,
379 true,
380 usize::MAX,
381 &Default::default(),
382 executor.clone(),
383 ));
384
385 let (online_contacts, offline_contacts) = matches
386 .iter()
387 .partition::<Vec<_>, _>(|mat| contacts[mat.candidate_id].online);
388
389 for (matches, section) in [
390 (online_contacts, Section::Online),
391 (offline_contacts, Section::Offline),
392 ] {
393 if !matches.is_empty() {
394 self.entries.push(ContactEntry::Header(section));
395 if !self.collapsed_sections.contains(§ion) {
396 for mat in matches {
397 let contact = &contacts[mat.candidate_id];
398 self.entries.push(ContactEntry::Contact(contact.clone()));
399 }
400 }
401 }
402 }
403 }
404
405 if let Some(prev_selected_entry) = prev_selected_entry {
406 self.selection.take();
407 for (ix, entry) in self.entries.iter().enumerate() {
408 if *entry == prev_selected_entry {
409 self.selection = Some(ix);
410 break;
411 }
412 }
413 }
414
415 self.list_state.reset(self.entries.len());
416 cx.notify();
417 }
418
419 fn render_active_call(&self, cx: &mut RenderContext<Self>) -> Option<ElementBox> {
420 let room = ActiveCall::global(cx).read(cx).room()?;
421 let theme = &cx.global::<Settings>().theme.contacts_popover;
422
423 Some(
424 Flex::column()
425 .with_children(room.read(cx).pending_users().iter().map(|user| {
426 Flex::row()
427 .with_children(user.avatar.clone().map(|avatar| {
428 Image::new(avatar)
429 .with_style(theme.contact_avatar)
430 .aligned()
431 .left()
432 .boxed()
433 }))
434 .with_child(
435 Label::new(
436 user.github_login.clone(),
437 theme.contact_username.text.clone(),
438 )
439 .contained()
440 .with_style(theme.contact_username.container)
441 .aligned()
442 .left()
443 .flex(1., true)
444 .boxed(),
445 )
446 .constrained()
447 .with_height(theme.row_height)
448 .contained()
449 .with_style(theme.contact_row.default)
450 .boxed()
451 }))
452 .boxed(),
453 )
454 }
455
456 fn render_header(
457 section: Section,
458 theme: &theme::ContactsPopover,
459 is_selected: bool,
460 is_collapsed: bool,
461 cx: &mut RenderContext<Self>,
462 ) -> ElementBox {
463 enum Header {}
464
465 let header_style = theme.header_row.style_for(Default::default(), is_selected);
466 let text = match section {
467 Section::Requests => "Requests",
468 Section::Online => "Online",
469 Section::Offline => "Offline",
470 };
471 let icon_size = theme.section_icon_size;
472 MouseEventHandler::<Header>::new(section as usize, cx, |_, _| {
473 Flex::row()
474 .with_child(
475 Svg::new(if is_collapsed {
476 "icons/chevron_right_8.svg"
477 } else {
478 "icons/chevron_down_8.svg"
479 })
480 .with_color(header_style.text.color)
481 .constrained()
482 .with_max_width(icon_size)
483 .with_max_height(icon_size)
484 .aligned()
485 .constrained()
486 .with_width(icon_size)
487 .boxed(),
488 )
489 .with_child(
490 Label::new(text.to_string(), header_style.text.clone())
491 .aligned()
492 .left()
493 .contained()
494 .with_margin_left(theme.contact_username.container.margin.left)
495 .flex(1., true)
496 .boxed(),
497 )
498 .constrained()
499 .with_height(theme.row_height)
500 .contained()
501 .with_style(header_style.container)
502 .boxed()
503 })
504 .with_cursor_style(CursorStyle::PointingHand)
505 .on_click(MouseButton::Left, move |_, cx| {
506 cx.dispatch_action(ToggleExpanded(section))
507 })
508 .boxed()
509 }
510
511 fn render_contact(
512 contact: &Contact,
513 project: &ModelHandle<Project>,
514 theme: &theme::ContactsPopover,
515 is_selected: bool,
516 cx: &mut RenderContext<Self>,
517 ) -> ElementBox {
518 let online = contact.online;
519 let user_id = contact.user.id;
520 let initial_project = project.clone();
521 let mut element =
522 MouseEventHandler::<Contact>::new(contact.user.id as usize, cx, |_, _| {
523 Flex::row()
524 .with_children(contact.user.avatar.clone().map(|avatar| {
525 let status_badge = if contact.online {
526 Some(
527 Empty::new()
528 .collapsed()
529 .contained()
530 .with_style(if contact.busy {
531 theme.contact_status_busy
532 } else {
533 theme.contact_status_free
534 })
535 .aligned()
536 .boxed(),
537 )
538 } else {
539 None
540 };
541 Stack::new()
542 .with_child(
543 Image::new(avatar)
544 .with_style(theme.contact_avatar)
545 .aligned()
546 .left()
547 .boxed(),
548 )
549 .with_children(status_badge)
550 .boxed()
551 }))
552 .with_child(
553 Label::new(
554 contact.user.github_login.clone(),
555 theme.contact_username.text.clone(),
556 )
557 .contained()
558 .with_style(theme.contact_username.container)
559 .aligned()
560 .left()
561 .flex(1., true)
562 .boxed(),
563 )
564 .constrained()
565 .with_height(theme.row_height)
566 .contained()
567 .with_style(*theme.contact_row.style_for(Default::default(), is_selected))
568 .boxed()
569 })
570 .on_click(MouseButton::Left, move |_, cx| {
571 if online {
572 cx.dispatch_action(Call {
573 recipient_user_id: user_id,
574 initial_project: Some(initial_project.clone()),
575 });
576 }
577 });
578
579 if online {
580 element = element.with_cursor_style(CursorStyle::PointingHand);
581 }
582
583 element.boxed()
584 }
585
586 fn render_contact_request(
587 user: Arc<User>,
588 user_store: ModelHandle<UserStore>,
589 theme: &theme::ContactsPopover,
590 is_incoming: bool,
591 is_selected: bool,
592 cx: &mut RenderContext<Self>,
593 ) -> ElementBox {
594 enum Decline {}
595 enum Accept {}
596 enum Cancel {}
597
598 let mut row = Flex::row()
599 .with_children(user.avatar.clone().map(|avatar| {
600 Image::new(avatar)
601 .with_style(theme.contact_avatar)
602 .aligned()
603 .left()
604 .boxed()
605 }))
606 .with_child(
607 Label::new(
608 user.github_login.clone(),
609 theme.contact_username.text.clone(),
610 )
611 .contained()
612 .with_style(theme.contact_username.container)
613 .aligned()
614 .left()
615 .flex(1., true)
616 .boxed(),
617 );
618
619 let user_id = user.id;
620 let is_contact_request_pending = user_store.read(cx).is_contact_request_pending(&user);
621 let button_spacing = theme.contact_button_spacing;
622
623 if is_incoming {
624 row.add_children([
625 MouseEventHandler::<Decline>::new(user.id as usize, cx, |mouse_state, _| {
626 let button_style = if is_contact_request_pending {
627 &theme.disabled_button
628 } else {
629 theme.contact_button.style_for(mouse_state, false)
630 };
631 render_icon_button(button_style, "icons/x_mark_8.svg")
632 .aligned()
633 .boxed()
634 })
635 .with_cursor_style(CursorStyle::PointingHand)
636 .on_click(MouseButton::Left, move |_, cx| {
637 cx.dispatch_action(RespondToContactRequest {
638 user_id,
639 accept: false,
640 })
641 })
642 .contained()
643 .with_margin_right(button_spacing)
644 .boxed(),
645 MouseEventHandler::<Accept>::new(user.id as usize, cx, |mouse_state, _| {
646 let button_style = if is_contact_request_pending {
647 &theme.disabled_button
648 } else {
649 theme.contact_button.style_for(mouse_state, false)
650 };
651 render_icon_button(button_style, "icons/check_8.svg")
652 .aligned()
653 .flex_float()
654 .boxed()
655 })
656 .with_cursor_style(CursorStyle::PointingHand)
657 .on_click(MouseButton::Left, move |_, cx| {
658 cx.dispatch_action(RespondToContactRequest {
659 user_id,
660 accept: true,
661 })
662 })
663 .boxed(),
664 ]);
665 } else {
666 row.add_child(
667 MouseEventHandler::<Cancel>::new(user.id as usize, cx, |mouse_state, _| {
668 let button_style = if is_contact_request_pending {
669 &theme.disabled_button
670 } else {
671 theme.contact_button.style_for(mouse_state, false)
672 };
673 render_icon_button(button_style, "icons/x_mark_8.svg")
674 .aligned()
675 .flex_float()
676 .boxed()
677 })
678 .with_padding(Padding::uniform(2.))
679 .with_cursor_style(CursorStyle::PointingHand)
680 .on_click(MouseButton::Left, move |_, cx| {
681 cx.dispatch_action(RemoveContact(user_id))
682 })
683 .flex_float()
684 .boxed(),
685 );
686 }
687
688 row.constrained()
689 .with_height(theme.row_height)
690 .contained()
691 .with_style(*theme.contact_row.style_for(Default::default(), is_selected))
692 .boxed()
693 }
694
695 fn call(&mut self, action: &Call, cx: &mut ViewContext<Self>) {
696 ActiveCall::global(cx)
697 .update(cx, |active_call, cx| {
698 active_call.invite(action.recipient_user_id, action.initial_project.clone(), cx)
699 })
700 .detach_and_log_err(cx);
701 }
702}
703
704impl Entity for ContactsPopover {
705 type Event = Event;
706}
707
708impl View for ContactsPopover {
709 fn ui_name() -> &'static str {
710 "ContactsPopover"
711 }
712
713 fn keymap_context(&self, _: &AppContext) -> keymap::Context {
714 let mut cx = Self::default_keymap_context();
715 cx.set.insert("menu".into());
716 cx
717 }
718
719 fn render(&mut self, cx: &mut RenderContext<Self>) -> ElementBox {
720 enum AddContact {}
721 let theme = cx.global::<Settings>().theme.clone();
722
723 Flex::column()
724 .with_child(
725 Flex::row()
726 .with_child(
727 ChildView::new(self.filter_editor.clone())
728 .contained()
729 .with_style(theme.contacts_popover.user_query_editor.container)
730 .flex(1., true)
731 .boxed(),
732 )
733 .with_child(
734 MouseEventHandler::<AddContact>::new(0, cx, |_, _| {
735 Svg::new("icons/user_plus_16.svg")
736 .with_color(theme.contacts_popover.add_contact_button.color)
737 .constrained()
738 .with_height(16.)
739 .contained()
740 .with_style(theme.contacts_popover.add_contact_button.container)
741 .aligned()
742 .boxed()
743 })
744 .with_cursor_style(CursorStyle::PointingHand)
745 .on_click(MouseButton::Left, |_, cx| {
746 cx.dispatch_action(contact_finder::Toggle)
747 })
748 .boxed(),
749 )
750 .constrained()
751 .with_height(theme.contacts_popover.user_query_editor_height)
752 .boxed(),
753 )
754 .with_children(self.render_active_call(cx))
755 .with_child(List::new(self.list_state.clone()).flex(1., false).boxed())
756 .with_children(
757 self.user_store
758 .read(cx)
759 .invite_info()
760 .cloned()
761 .and_then(|info| {
762 enum InviteLink {}
763
764 if info.count > 0 {
765 Some(
766 MouseEventHandler::<InviteLink>::new(0, cx, |state, cx| {
767 let style = theme
768 .contacts_popover
769 .invite_row
770 .style_for(state, false)
771 .clone();
772
773 let copied = cx.read_from_clipboard().map_or(false, |item| {
774 item.text().as_str() == info.url.as_ref()
775 });
776
777 Label::new(
778 format!(
779 "{} invite link ({} left)",
780 if copied { "Copied" } else { "Copy" },
781 info.count
782 ),
783 style.label.clone(),
784 )
785 .aligned()
786 .left()
787 .constrained()
788 .with_height(theme.contacts_popover.row_height)
789 .contained()
790 .with_style(style.container)
791 .boxed()
792 })
793 .with_cursor_style(CursorStyle::PointingHand)
794 .on_click(MouseButton::Left, move |_, cx| {
795 cx.write_to_clipboard(ClipboardItem::new(info.url.to_string()));
796 cx.notify();
797 })
798 .boxed(),
799 )
800 } else {
801 None
802 }
803 }),
804 )
805 .contained()
806 .with_style(theme.contacts_popover.container)
807 .constrained()
808 .with_width(theme.contacts_popover.width)
809 .with_height(theme.contacts_popover.height)
810 .boxed()
811 }
812
813 fn on_focus_in(&mut self, _: gpui::AnyViewHandle, cx: &mut ViewContext<Self>) {
814 if !self.filter_editor.is_focused(cx) {
815 cx.focus(&self.filter_editor);
816 }
817 }
818
819 fn on_focus_out(&mut self, _: gpui::AnyViewHandle, cx: &mut ViewContext<Self>) {
820 if !self.filter_editor.is_focused(cx) {
821 cx.emit(Event::Dismissed);
822 }
823 }
824}
825
826fn render_icon_button(style: &IconButton, svg_path: &'static str) -> impl Element {
827 Svg::new(svg_path)
828 .with_color(style.color)
829 .constrained()
830 .with_width(style.icon_width)
831 .aligned()
832 .contained()
833 .with_style(style.container)
834 .constrained()
835 .with_width(style.button_width)
836 .with_height(style.button_width)
837}