Skip to main content

fractal/session_view/sidebar/
row.rs

1use adw::{prelude::*, subclass::prelude::*};
2use gettextrs::gettext;
3use gtk::{gdk, gio, glib, glib::clone};
4use ruma::api::client::receipt::create_receipt::v3::ReceiptType;
5use tracing::error;
6
7use super::{
8    Sidebar, SidebarIconItemRow, SidebarRoomRow, SidebarSectionRow, SidebarVerificationRow,
9};
10use crate::{
11    components::{ContextMenuBin, confirm_leave_room_dialog},
12    prelude::*,
13    session::{
14        IdentityVerification, ReceiptPosition, Room, RoomCategory, SidebarIconItem,
15        SidebarIconItemType, SidebarSection, TargetRoomCategory, User,
16    },
17    spawn, spawn_tokio, toast,
18    utils::BoundObjectWeakRef,
19};
20
21mod imp {
22    use std::cell::RefCell;
23
24    use super::*;
25
26    #[derive(Debug, Default, glib::Properties)]
27    #[properties(wrapper_type = super::SidebarRow)]
28    pub struct SidebarRow {
29        /// The ancestor sidebar of this row.
30        #[property(get, set = Self::set_sidebar, construct_only)]
31        sidebar: BoundObjectWeakRef<Sidebar>,
32        /// The item of this row.
33        #[property(get, set = Self::set_item, explicit_notify, nullable)]
34        item: RefCell<Option<glib::Object>>,
35        room_handler: RefCell<Option<glib::SignalHandlerId>>,
36        room_join_rule_handler: RefCell<Option<glib::SignalHandlerId>>,
37        room_is_read_handler: RefCell<Option<glib::SignalHandlerId>>,
38    }
39
40    #[glib::object_subclass]
41    impl ObjectSubclass for SidebarRow {
42        const NAME: &'static str = "SidebarRow";
43        type Type = super::SidebarRow;
44        type ParentType = ContextMenuBin;
45
46        fn class_init(klass: &mut Self::Class) {
47            klass.set_css_name("sidebar-row");
48            klass.set_accessible_role(gtk::AccessibleRole::ListItem);
49        }
50    }
51
52    #[glib::derived_properties]
53    impl ObjectImpl for SidebarRow {
54        fn constructed(&self) {
55            self.parent_constructed();
56
57            // Set up drop controller
58            let drop = gtk::DropTarget::builder()
59                .actions(gdk::DragAction::MOVE)
60                .formats(&gdk::ContentFormats::for_type(Room::static_type()))
61                .build();
62            drop.connect_accept(clone!(
63                #[weak(rename_to = imp)]
64                self,
65                #[upgrade_or]
66                false,
67                move |_, drop| imp.drop_accept(drop)
68            ));
69            drop.connect_leave(clone!(
70                #[weak(rename_to = imp)]
71                self,
72                move |_| {
73                    imp.drop_leave();
74                }
75            ));
76            drop.connect_drop(clone!(
77                #[weak(rename_to = imp)]
78                self,
79                #[upgrade_or]
80                false,
81                move |_, v, _, _| imp.drop_end(v)
82            ));
83            self.obj().add_controller(drop);
84        }
85
86        fn dispose(&self) {
87            if let Some(room) = self.room() {
88                if let Some(handler) = self.room_join_rule_handler.take() {
89                    room.join_rule().disconnect(handler);
90                }
91                if let Some(handler) = self.room_is_read_handler.take() {
92                    room.disconnect(handler);
93                }
94            }
95        }
96    }
97
98    impl WidgetImpl for SidebarRow {}
99
100    impl ContextMenuBinImpl for SidebarRow {
101        fn menu_opened(&self) {
102            if !self
103                .item
104                .borrow()
105                .as_ref()
106                .is_some_and(glib::Object::is::<Room>)
107            {
108                // No context menu.
109                return;
110            }
111
112            let obj = self.obj();
113            if let Some(sidebar) = obj.sidebar() {
114                let popover = sidebar.room_row_popover();
115                obj.set_popover(Some(popover.clone()));
116            }
117        }
118    }
119
120    impl SidebarRow {
121        /// Set the ancestor sidebar of this row.
122        fn set_sidebar(&self, sidebar: &Sidebar) {
123            let drop_source_category_handler =
124                sidebar.connect_drop_source_category_changed(clone!(
125                    #[weak(rename_to = imp)]
126                    self,
127                    move |_| {
128                        imp.update_for_drop_source_category();
129                    }
130                ));
131
132            let drop_active_target_category_handler = sidebar
133                .connect_drop_active_target_category_changed(clone!(
134                    #[weak(rename_to = imp)]
135                    self,
136                    move |_| {
137                        imp.update_for_drop_active_target_category();
138                    }
139                ));
140
141            self.sidebar.set(
142                sidebar,
143                vec![
144                    drop_source_category_handler,
145                    drop_active_target_category_handler,
146                ],
147            );
148        }
149
150        /// Set the item of this row.
151        fn set_item(&self, item: Option<glib::Object>) {
152            if *self.item.borrow() == item {
153                return;
154            }
155            let obj = self.obj();
156
157            if let Some(room) = self.room() {
158                if let Some(handler) = self.room_handler.take() {
159                    room.disconnect(handler);
160                }
161                if let Some(handler) = self.room_join_rule_handler.take() {
162                    room.join_rule().disconnect(handler);
163                }
164                if let Some(handler) = self.room_is_read_handler.take() {
165                    room.disconnect(handler);
166                }
167            }
168
169            self.item.replace(item.clone());
170
171            self.update_context_menu();
172
173            if let Some(item) = item {
174                if let Some(section) = item.downcast_ref::<SidebarSection>() {
175                    let child = obj.child_or_else::<SidebarSectionRow>(|| {
176                        let child = SidebarSectionRow::new();
177                        obj.update_relation(&[gtk::accessible::Relation::LabelledBy(&[
178                            child.labelled_by()
179                        ])]);
180                        child
181                    });
182                    child.set_section(Some(section.clone()));
183                } else if let Some(room) = item.downcast_ref::<Room>() {
184                    let child = obj.child_or_default::<SidebarRoomRow>();
185
186                    let room_is_direct_handler = room.connect_is_direct_notify(clone!(
187                        #[weak(rename_to = imp)]
188                        self,
189                        move |_| {
190                            imp.update_context_menu();
191                        }
192                    ));
193                    self.room_handler.replace(Some(room_is_direct_handler));
194                    let room_join_rule_handler =
195                        room.join_rule().connect_we_can_join_notify(clone!(
196                            #[weak(rename_to = imp)]
197                            self,
198                            move |_| {
199                                imp.update_context_menu();
200                            }
201                        ));
202                    self.room_join_rule_handler
203                        .replace(Some(room_join_rule_handler));
204
205                    let room_is_read_handler = room.connect_is_read_notify(clone!(
206                        #[weak(rename_to = imp)]
207                        self,
208                        move |_| {
209                            imp.update_context_menu();
210                        }
211                    ));
212                    self.room_is_read_handler
213                        .replace(Some(room_is_read_handler));
214
215                    child.set_room(Some(room.clone()));
216                } else if let Some(icon_item) = item.downcast_ref::<SidebarIconItem>() {
217                    let child = obj.child_or_default::<SidebarIconItemRow>();
218                    child.set_icon_item(Some(icon_item.clone()));
219                } else if let Some(verification) = item.downcast_ref::<IdentityVerification>() {
220                    let child = obj.child_or_default::<SidebarVerificationRow>();
221                    child.set_identity_verification(Some(verification.clone()));
222                } else {
223                    panic!("Wrong row item: {item:?}");
224                }
225
226                self.update_for_drop_source_category();
227            }
228
229            self.update_context_menu();
230            obj.notify_item();
231        }
232
233        /// Get the `Room` of this item, if this is a room row.
234        pub(super) fn room(&self) -> Option<Room> {
235            self.item.borrow().clone().and_downcast()
236        }
237
238        /// Get the `RoomCategory` of this row, if any.
239        ///
240        /// If this does not display a room or a section containing rooms,
241        /// returns `None`.
242        pub(super) fn room_category(&self) -> Option<RoomCategory> {
243            let borrowed_item = self.item.borrow();
244            let item = borrowed_item.as_ref()?;
245
246            if let Some(room) = item.downcast_ref::<Room>() {
247                Some(room.category())
248            } else {
249                item.downcast_ref::<SidebarSection>()
250                    .and_then(|section| section.name().into_room_category())
251            }
252        }
253
254        /// Get the `TargetRoomCategory` of this row, if any.
255        pub(super) fn target_room_category(&self) -> Option<TargetRoomCategory> {
256            self.room_category()
257                .and_then(RoomCategory::to_target_room_category)
258        }
259
260        /// Get the [`SidebarIconItemType`] of the icon item displayed by this
261        /// row, if any.
262        pub(super) fn item_type(&self) -> Option<SidebarIconItemType> {
263            let borrowed_item = self.item.borrow();
264            borrowed_item
265                .as_ref()?
266                .downcast_ref::<SidebarIconItem>()
267                .map(SidebarIconItem::item_type)
268        }
269
270        /// Whether this has a room context menu.
271        fn has_room_context_menu(&self) -> bool {
272            self.room().is_some_and(|r| {
273                matches!(
274                    r.category(),
275                    RoomCategory::Invited
276                        | RoomCategory::Favorite
277                        | RoomCategory::Normal
278                        | RoomCategory::LowPriority
279                        | RoomCategory::Left
280                )
281            })
282        }
283
284        /// Update the context menu according to the current state.
285        fn update_context_menu(&self) {
286            let obj = self.obj();
287
288            if !self.has_room_context_menu() {
289                obj.insert_action_group("room-row", None::<&gio::ActionGroup>);
290                obj.set_has_context_menu(false);
291                return;
292            }
293
294            obj.insert_action_group("room-row", self.room_actions().as_ref());
295            obj.set_has_context_menu(true);
296        }
297
298        /// An action group with the available room actions.
299        #[allow(clippy::too_many_lines)]
300        fn room_actions(&self) -> Option<gio::SimpleActionGroup> {
301            let room = self.room()?;
302
303            let action_group = gio::SimpleActionGroup::new();
304            let category = room.category();
305
306            match category {
307                RoomCategory::Knocked => {
308                    action_group.add_action_entries([gio::ActionEntry::builder(
309                        "retract-invite-request",
310                    )
311                    .activate(clone!(
312                        #[weak(rename_to = imp)]
313                        self,
314                        move |_, _, _| {
315                            if let Some(room) = imp.room() {
316                                spawn!(async move {
317                                    imp.set_room_category(&room, TargetRoomCategory::Left).await;
318                                });
319                            }
320                        }
321                    ))
322                    .build()]);
323                }
324                RoomCategory::Invited => {
325                    action_group.add_action_entries([
326                        gio::ActionEntry::builder("accept-invite")
327                            .activate(clone!(
328                                #[weak(rename_to = imp)]
329                                self,
330                                move |_, _, _| {
331                                    if let Some(room) = imp.room() {
332                                        spawn!(async move {
333                                            imp.set_room_category(
334                                                &room,
335                                                TargetRoomCategory::Normal,
336                                            )
337                                            .await;
338                                        });
339                                    }
340                                }
341                            ))
342                            .build(),
343                        gio::ActionEntry::builder("decline-invite")
344                            .activate(clone!(
345                                #[weak(rename_to = imp)]
346                                self,
347                                move |_, _, _| {
348                                    if let Some(room) = imp.room() {
349                                        spawn!(async move {
350                                            imp.set_room_category(&room, TargetRoomCategory::Left)
351                                                .await;
352                                        });
353                                    }
354                                }
355                            ))
356                            .build(),
357                    ]);
358                }
359                RoomCategory::Favorite | RoomCategory::Normal | RoomCategory::LowPriority => {
360                    if matches!(category, RoomCategory::Favorite | RoomCategory::LowPriority) {
361                        action_group.add_action_entries([gio::ActionEntry::builder("set-normal")
362                            .activate(clone!(
363                                #[weak(rename_to = imp)]
364                                self,
365                                move |_, _, _| {
366                                    if let Some(room) = imp.room() {
367                                        spawn!(async move {
368                                            imp.set_room_category(
369                                                &room,
370                                                TargetRoomCategory::Normal,
371                                            )
372                                            .await;
373                                        });
374                                    }
375                                }
376                            ))
377                            .build()]);
378                    }
379
380                    if matches!(category, RoomCategory::Normal | RoomCategory::LowPriority) {
381                        action_group.add_action_entries([gio::ActionEntry::builder(
382                            "set-favorite",
383                        )
384                        .activate(clone!(
385                            #[weak(rename_to = imp)]
386                            self,
387                            move |_, _, _| {
388                                if let Some(room) = imp.room() {
389                                    spawn!(async move {
390                                        imp.set_room_category(&room, TargetRoomCategory::Favorite)
391                                            .await;
392                                    });
393                                }
394                            }
395                        ))
396                        .build()]);
397                    }
398
399                    if matches!(category, RoomCategory::Favorite | RoomCategory::Normal) {
400                        action_group.add_action_entries([gio::ActionEntry::builder(
401                            "set-lowpriority",
402                        )
403                        .activate(clone!(
404                            #[weak(rename_to = imp)]
405                            self,
406                            move |_, _, _| {
407                                if let Some(room) = imp.room() {
408                                    spawn!(async move {
409                                        imp.set_room_category(
410                                            &room,
411                                            TargetRoomCategory::LowPriority,
412                                        )
413                                        .await;
414                                    });
415                                }
416                            }
417                        ))
418                        .build()]);
419                    }
420
421                    action_group.add_action_entries([gio::ActionEntry::builder("leave")
422                        .activate(clone!(
423                            #[weak(rename_to = imp)]
424                            self,
425                            move |_, _, _| {
426                                if let Some(room) = imp.room() {
427                                    spawn!(async move {
428                                        imp.set_room_category(&room, TargetRoomCategory::Left)
429                                            .await;
430                                    });
431                                }
432                            }
433                        ))
434                        .build()]);
435
436                    if room.is_read() {
437                        action_group.add_action_entries([gio::ActionEntry::builder(
438                            "mark-as-unread",
439                        )
440                        .activate(clone!(
441                            #[weak]
442                            room,
443                            move |_, _, _| {
444                                spawn!(async move {
445                                    room.mark_as_unread().await;
446                                });
447                            }
448                        ))
449                        .build()]);
450                    } else {
451                        action_group.add_action_entries([gio::ActionEntry::builder(
452                            "mark-as-read",
453                        )
454                        .activate(clone!(
455                            #[weak]
456                            room,
457                            move |_, _, _| {
458                                spawn!(async move {
459                                    room.send_receipt(ReceiptType::Read, ReceiptPosition::End)
460                                        .await;
461                                });
462                            }
463                        ))
464                        .build()]);
465                    }
466                }
467                RoomCategory::Left => {
468                    if room.join_rule().we_can_join() {
469                        action_group.add_action_entries([gio::ActionEntry::builder("join")
470                            .activate(clone!(
471                                #[weak(rename_to = imp)]
472                                self,
473                                move |_, _, _| {
474                                    if let Some(room) = imp.room() {
475                                        spawn!(async move {
476                                            imp.set_room_category(
477                                                &room,
478                                                TargetRoomCategory::Normal,
479                                            )
480                                            .await;
481                                        });
482                                    }
483                                }
484                            ))
485                            .build()]);
486                    }
487
488                    action_group.add_action_entries([gio::ActionEntry::builder("forget")
489                        .activate(clone!(
490                            #[weak(rename_to = imp)]
491                            self,
492                            move |_, _, _| {
493                                if let Some(room) = imp.room() {
494                                    spawn!(async move {
495                                        imp.forget_room(&room).await;
496                                    });
497                                }
498                            }
499                        ))
500                        .build()]);
501                }
502                RoomCategory::Outdated | RoomCategory::Space | RoomCategory::Ignored => {}
503            }
504
505            if matches!(
506                category,
507                RoomCategory::Favorite
508                    | RoomCategory::Normal
509                    | RoomCategory::LowPriority
510                    | RoomCategory::Left
511            ) {
512                if room.is_direct() {
513                    action_group.add_action_entries([gio::ActionEntry::builder(
514                        "unset-direct-chat",
515                    )
516                    .activate(clone!(
517                        #[weak(rename_to = imp)]
518                        self,
519                        move |_, _, _| {
520                            if let Some(room) = imp.room() {
521                                spawn!(async move {
522                                    imp.set_room_is_direct(&room, false).await;
523                                });
524                            }
525                        }
526                    ))
527                    .build()]);
528                } else {
529                    action_group.add_action_entries([gio::ActionEntry::builder("set-direct-chat")
530                        .activate(clone!(
531                            #[weak(rename_to = imp)]
532                            self,
533                            move |_, _, _| {
534                                if let Some(room) = imp.room() {
535                                    spawn!(async move {
536                                        imp.set_room_is_direct(&room, true).await;
537                                    });
538                                }
539                            }
540                        ))
541                        .build()]);
542                }
543            }
544
545            Some(action_group)
546        }
547
548        /// Update the disabled or empty state of this drop target.
549        fn update_for_drop_source_category(&self) {
550            let obj = self.obj();
551            let source_category = self.sidebar.obj().and_then(|s| s.drop_source_category());
552
553            if let Some(source_category) = source_category {
554                if self
555                    .target_room_category()
556                    .is_some_and(|row_category| source_category.can_change_to(row_category))
557                {
558                    obj.remove_css_class("drop-disabled");
559
560                    if self
561                        .item
562                        .borrow()
563                        .as_ref()
564                        .and_then(glib::Object::downcast_ref)
565                        .is_some_and(SidebarSection::is_empty)
566                    {
567                        obj.add_css_class("drop-empty");
568                    } else {
569                        obj.remove_css_class("drop-empty");
570                    }
571                } else {
572                    let is_forget_item = self
573                        .item_type()
574                        .is_some_and(|item_type| item_type == SidebarIconItemType::Forget);
575                    if is_forget_item && source_category == RoomCategory::Left {
576                        obj.remove_css_class("drop-disabled");
577                    } else {
578                        obj.add_css_class("drop-disabled");
579                        obj.remove_css_class("drop-empty");
580                    }
581                }
582            } else {
583                // Clear style
584                obj.remove_css_class("drop-disabled");
585                obj.remove_css_class("drop-empty");
586                obj.remove_css_class("drop-active");
587            }
588
589            if let Some(section_row) = obj.child().and_downcast::<SidebarSectionRow>() {
590                section_row.set_show_label_for_room_category(source_category);
591            }
592        }
593
594        /// Update the active state of this drop target.
595        fn update_for_drop_active_target_category(&self) {
596            let obj = self.obj();
597
598            let Some(room_category) = self.room_category() else {
599                obj.remove_css_class("drop-active");
600                return;
601            };
602
603            let target_category = self
604                .sidebar
605                .obj()
606                .and_then(|s| s.drop_active_target_category());
607
608            if target_category.is_some_and(|target_category| target_category == room_category) {
609                obj.add_css_class("drop-active");
610            } else {
611                obj.remove_css_class("drop-active");
612            }
613        }
614
615        /// Handle the drag-n-drop hovering this row.
616        fn drop_accept(&self, drop: &gdk::Drop) -> bool {
617            let Some(sidebar) = self.sidebar.obj() else {
618                return false;
619            };
620
621            let room = drop
622                .drag()
623                .map(|drag| drag.content())
624                .and_then(|content| content.value(Room::static_type()).ok())
625                .and_then(|value| value.get::<Room>().ok());
626            if let Some(room) = room {
627                if let Some(target_category) = self.target_room_category() {
628                    if room.category().can_change_to(target_category) {
629                        sidebar.set_drop_active_target_category(Some(target_category));
630                        return true;
631                    }
632                } else if self
633                    .item_type()
634                    .is_some_and(|item_type| item_type == SidebarIconItemType::Forget)
635                    && room.category() == RoomCategory::Left
636                {
637                    self.obj().add_css_class("drop-active");
638                    sidebar.set_drop_active_target_category(None);
639                    return true;
640                }
641            }
642
643            false
644        }
645
646        /// Handle the drag-n-drop leaving this row.
647        fn drop_leave(&self) {
648            self.obj().remove_css_class("drop-active");
649            if let Some(sidebar) = self.sidebar.obj() {
650                sidebar.set_drop_active_target_category(None);
651            }
652        }
653
654        /// Handle the drop on this row.
655        fn drop_end(&self, value: &glib::Value) -> bool {
656            let mut ret = false;
657            if let Ok(room) = value.get::<Room>() {
658                if let Some(target_category) = self.target_room_category() {
659                    if room.category().can_change_to(target_category) {
660                        spawn!(clone!(
661                            #[weak(rename_to = imp)]
662                            self,
663                            async move {
664                                imp.set_room_category(&room, target_category).await;
665                            }
666                        ));
667                        ret = true;
668                    }
669                } else if self
670                    .item_type()
671                    .is_some_and(|item_type| item_type == SidebarIconItemType::Forget)
672                    && room.category() == RoomCategory::Left
673                {
674                    spawn!(clone!(
675                        #[weak(rename_to = imp)]
676                        self,
677                        async move {
678                            imp.forget_room(&room).await;
679                        }
680                    ));
681                    ret = true;
682                }
683            }
684            if let Some(sidebar) = self.sidebar.obj() {
685                sidebar.set_drop_source_category(None);
686            }
687            ret
688        }
689
690        /// Change the category of the given room.
691        async fn set_room_category(&self, room: &Room, category: TargetRoomCategory) {
692            let obj = self.obj();
693
694            let ignored_inviter = if category == TargetRoomCategory::Left {
695                let Some(response) = confirm_leave_room_dialog(room, &*obj).await else {
696                    return;
697                };
698
699                response.ignore_inviter.then(|| room.inviter()).flatten()
700            } else {
701                None
702            };
703
704            let previous_category = room.category();
705            if room.change_category(category).await.is_err() {
706                match previous_category {
707                    RoomCategory::Invited => {
708                        if category == RoomCategory::Left {
709                            toast!(
710                                obj,
711                                gettext(
712                                    // Translators: Do NOT translate the content between '{' and '}', this
713                                    // is a variable name.
714                                    "Could not decline invitation for {room}",
715                                ),
716                                @room,
717                            );
718                        } else {
719                            toast!(
720                                obj,
721                                gettext(
722                                    // Translators: Do NOT translate the content between '{' and '}', this
723                                    // is a variable name.
724                                    "Could not accept invitation for {room}",
725                                ),
726                                @room,
727                            );
728                        }
729                    }
730                    RoomCategory::Left => {
731                        toast!(
732                            obj,
733                            gettext(
734                                // Translators: Do NOT translate the content between '{' and '}', this is a
735                                // variable name.
736                                "Could not join {room}",
737                            ),
738                            @room,
739                        );
740                    }
741                    _ => {
742                        if category == RoomCategory::Left {
743                            toast!(
744                                obj,
745                                gettext(
746                                    // Translators: Do NOT translate the content between '{' and '}', this is a variable name.
747                                    "Could not leave {room}",
748                                ),
749                                @room,
750                            );
751                        } else {
752                            toast!(
753                                obj,
754                                gettext(
755                                    // Translators: Do NOT translate the content between '{' and '}', this is a variable name.
756                                    "Could not move {room} from {previous_category} to {new_category}",
757                                ),
758                                @room,
759                                previous_category = previous_category.to_string(),
760                                new_category = RoomCategory::from(category).to_string(),
761                            );
762                        }
763                    }
764                }
765            }
766
767            if let Some(inviter) = ignored_inviter
768                && inviter.upcast::<User>().ignore().await.is_err()
769            {
770                toast!(obj, gettext("Could not ignore user"));
771            }
772        }
773
774        /// Forget the given room.
775        async fn forget_room(&self, room: &Room) {
776            if room.forget().await.is_err() {
777                toast!(
778                    self.obj(),
779                    // Translators: Do NOT translate the content between '{' and '}', this is a variable name.
780                    gettext("Could not forget {room}"),
781                    @room,
782                );
783            }
784        }
785
786        /// Set or unset the room as a direct chat.
787        async fn set_room_is_direct(&self, room: &Room, is_direct: bool) {
788            let matrix_room = room.matrix_room().clone();
789            let handle = spawn_tokio!(async move { matrix_room.set_is_direct(is_direct).await });
790
791            if let Err(error) = handle.await.unwrap() {
792                let obj = self.obj();
793
794                if is_direct {
795                    error!("Could not mark room as direct chat: {error}");
796                    // Translators: Do NOT translate the content between '{' and '}', this is a
797                    // variable name.
798                    toast!(obj, gettext("Could not mark {room} as direct chat"), @room);
799                } else {
800                    error!("Could not unmark room as direct chat: {error}");
801                    // Translators: Do NOT translate the content between '{' and '}', this is a
802                    // variable name.
803                    toast!(obj, gettext("Could not unmark {room} as direct chat"), @room);
804                }
805            }
806        }
807    }
808}
809
810glib::wrapper! {
811    /// A row of the sidebar.
812    pub struct SidebarRow(ObjectSubclass<imp::SidebarRow>)
813        @extends gtk::Widget, ContextMenuBin,
814        @implements gtk::Accessible, gtk::Buildable, gtk::ConstraintTarget;
815}
816
817impl SidebarRow {
818    pub fn new(sidebar: &Sidebar) -> Self {
819        glib::Object::builder().property("sidebar", sidebar).build()
820    }
821}
822
823impl ChildPropertyExt for SidebarRow {
824    fn child_property(&self) -> Option<gtk::Widget> {
825        self.child()
826    }
827
828    fn set_child_property(&self, child: Option<&impl IsA<gtk::Widget>>) {
829        self.set_child(child);
830    }
831}