[gnome-builder] libide-gtk: add internal GTK 4 helper library



commit 0f115b07e71b6d7d823fcde2714e655044bfadc7
Author: Christian Hergert <chergert redhat com>
Date:   Mon Jul 11 17:39:48 2022 -0700

    libide-gtk: add internal GTK 4 helper library
    
    This makes portin to GTK 4 a bit easier so we can reuse components and then
    hopefully remove them from libide-gtk over time.
    
    Most things come from libdazzle, and were ported to GTK 4 or in other ways
    were useful as a helper in porting to GTK 4.

 src/libide/gtk/icons/enter-keyboard-shortcut.svg  |  245 +++++
 src/libide/gtk/ide-animation.c                    | 1156 +++++++++++++++++++++
 src/libide/gtk/ide-animation.h                    |   85 ++
 src/libide/{gui => gtk}/ide-cell-renderer-fancy.c |   54 +-
 src/libide/{gui => gtk}/ide-cell-renderer-fancy.h |   18 +-
 src/libide/gtk/ide-entry-popover.c                |  427 ++++++++
 src/libide/gtk/ide-entry-popover.h                |  103 ++
 src/libide/gtk/ide-entry-popover.ui               |   58 ++
 src/libide/gtk/ide-enum-object.c                  |  182 ++++
 src/libide/gtk/ide-enum-object.h                  |   43 +
 src/libide/{gui => gtk}/ide-fancy-tree-view.c     |   34 +-
 src/libide/{gui => gtk}/ide-fancy-tree-view.h     |   14 +-
 src/libide/gtk/ide-file-chooser-entry.c           |  558 ++++++++++
 src/libide/gtk/ide-file-chooser-entry.h           |   47 +
 src/libide/gtk/ide-file-manager.c                 |  210 ++++
 src/libide/gtk/ide-file-manager.h                 |   30 +
 src/libide/gtk/ide-font-description.c             |  233 +++++
 src/libide/gtk/ide-font-description.h             |   33 +
 src/libide/gtk/ide-frame-source-private.h         |   34 +
 src/libide/gtk/ide-frame-source.c                 |  173 +++
 src/libide/gtk/ide-gtk-init.c                     |   58 ++
 src/libide/gtk/ide-gtk-private.h                  |   29 +
 src/libide/gtk/ide-gtk.c                          |  563 ++++++++++
 src/libide/gtk/ide-gtk.h                          |   73 ++
 src/libide/gtk/ide-joined-menu.c                  |  327 ++++++
 src/libide/gtk/ide-joined-menu.h                  |   53 +
 src/libide/gtk/ide-menu-manager.c                 |  679 ++++++++++++
 src/libide/gtk/ide-menu-manager.h                 |   57 +
 src/libide/gtk/ide-progress-icon.c                |  195 ++++
 src/libide/gtk/ide-progress-icon.h                |   44 +
 src/libide/gtk/ide-radio-box.c                    |  379 +++++++
 src/libide/gtk/ide-radio-box.h                    |   51 +
 src/libide/gtk/ide-search-entry.c                 |  230 ++++
 src/libide/gtk/ide-search-entry.h                 |   47 +
 src/libide/gtk/ide-search-entry.ui                |   30 +
 src/libide/gtk/ide-shortcut-accel-dialog.c        |  419 ++++++++
 src/libide/gtk/ide-shortcut-accel-dialog.h        |   51 +
 src/libide/gtk/ide-shortcut-accel-dialog.ui       |  118 +++
 src/libide/gtk/ide-tree-expander.c                |  598 +++++++++++
 src/libide/gtk/ide-tree-expander.h                |   69 ++
 src/libide/gtk/ide-truncate-model.c               |  358 +++++++
 src/libide/gtk/ide-truncate-model.h               |   53 +
 src/libide/gtk/libide-gtk.gresource.xml           |    9 +
 src/libide/gtk/libide-gtk.h                       |   42 +
 src/libide/gtk/meson.build                        |  127 +++
 src/libide/gui/libide-gui.h                       |    1 -
 src/libide/gui/meson.build                        |    4 -
 src/libide/meson.build                            |    1 +
 48 files changed, 8337 insertions(+), 65 deletions(-)
---
diff --git a/src/libide/gtk/icons/enter-keyboard-shortcut.svg 
b/src/libide/gtk/icons/enter-keyboard-shortcut.svg
new file mode 100644
index 000000000..b7ce2e4cc
--- /dev/null
+++ b/src/libide/gtk/icons/enter-keyboard-shortcut.svg
@@ -0,0 +1,245 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<!-- Created with Inkscape (http://www.inkscape.org/) -->
+
+<svg
+   xmlns:dc="http://purl.org/dc/elements/1.1/";
+   xmlns:cc="http://creativecommons.org/ns#";
+   xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#";
+   xmlns:svg="http://www.w3.org/2000/svg";
+   xmlns="http://www.w3.org/2000/svg";
+   xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd";
+   xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape";
+   width="256"
+   height="72"
+   viewBox="0 0 256 72.000001"
+   id="svg3611"
+   version="1.1"
+   inkscape:version="0.91 r13725"
+   sodipodi:docname="enter-keyboard-shortcut.svg">
+  <defs
+     id="defs3613" />
+  <sodipodi:namedview
+     id="base"
+     pagecolor="#ffffff"
+     bordercolor="#666666"
+     borderopacity="1.0"
+     inkscape:pageopacity="0.0"
+     inkscape:pageshadow="2"
+     inkscape:zoom="2.8"
+     inkscape:cx="137.98997"
+     inkscape:cy="34.663602"
+     inkscape:document-units="px"
+     inkscape:current-layer="layer1"
+     showgrid="false"
+     units="px"
+     inkscape:window-width="1366"
+     inkscape:window-height="704"
+     inkscape:window-x="0"
+     inkscape:window-y="27"
+     inkscape:window-maximized="1" />
+  <metadata
+     id="metadata3616">
+    <rdf:RDF>
+      <cc:Work
+         rdf:about="">
+        <dc:format>image/svg+xml</dc:format>
+        <dc:type
+           rdf:resource="http://purl.org/dc/dcmitype/StillImage"; />
+        <dc:title></dc:title>
+      </cc:Work>
+    </rdf:RDF>
+  </metadata>
+  <g
+     inkscape:label="Camada 1"
+     inkscape:groupmode="layer"
+     id="layer1"
+     transform="translate(0,-980.36216)">
+    <g
+       id="g3715"
+       transform="translate(-503.23415,689.94658)">
+      <path
+         d="m 509.66363,325.47627 c 5.53002,1.4185 18.51389,1.4185 24.29359,0 0.80721,-0.19813 
1.43306,0.67185 1.50029,1.50028 l 1.99751,13.05863 -1.50028,0 -28.28862,0 -1.50029,0 c 0.66583,-4.35287 
1.33167,-8.70575 1.99752,-13.05863 0.12564,-0.82161 0.69518,-1.70681 1.50028,-1.50028 z"
+         id="path27275"
+         
style="fill:#ffffff;fill-opacity:1;stroke:#000000;stroke-width:1.59999979;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+         sodipodi:nodetypes="sssccccss"
+         inkscape:connector-curvature="0" />
+      <path
+         d="m 543.62146,325.47627 c 5.53,1.4185 18.51387,1.4185 24.29357,0 0.80721,-0.19813 1.43308,0.67185 
1.50029,1.50028 l 1.99753,13.05863 -1.5003,0 -28.28862,0 -1.50029,0 c 0.66583,-4.35287 1.33168,-8.70575 
1.99752,-13.05863 0.12564,-0.82161 0.69518,-1.70681 1.5003,-1.50028 z"
+         id="path27277"
+         
style="fill:#ffffff;fill-opacity:1;stroke:#000000;stroke-width:1.59999979;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+         sodipodi:nodetypes="sssccccss"
+         inkscape:connector-curvature="0" />
+      <path
+         d="m 577.57927,325.47627 c 5.53,1.4185 18.51387,1.4185 24.29357,0 0.80721,-0.19813 1.43308,0.67185 
1.50029,1.50028 l 1.99753,13.05863 -1.5003,0 -28.28862,0 -1.50029,0 c 0.66583,-4.35287 1.33168,-8.70575 
1.99752,-13.05863 0.12564,-0.82161 0.6952,-1.70681 1.5003,-1.50028 z"
+         id="path27279"
+         
style="fill:#ffffff;fill-opacity:1;stroke:#000000;stroke-width:1.59999979;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+         sodipodi:nodetypes="sssccccss"
+         inkscape:connector-curvature="0" />
+      <path
+         d="m 611.12326,325.47627 c 5.53002,1.4185 18.51389,1.4185 24.29359,0 0.80721,-0.19813 
1.43306,0.67185 1.50029,1.50028 l 1.99751,13.05863 -1.50028,0 -28.28862,0 -1.50029,0 c 0.66583,-4.35287 
1.33167,-8.70575 1.99752,-13.05863 0.12564,-0.82161 0.69518,-1.70681 1.50028,-1.50028 z"
+         id="path5218"
+         
style="fill:#ffffff;fill-opacity:1;stroke:#000000;stroke-width:1.59999979;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+         sodipodi:nodetypes="sssccccss"
+         inkscape:connector-curvature="0" />
+      <path
+         d="m 645.08109,325.47627 c 5.53,1.4185 18.51387,1.4185 24.29357,0 0.80721,-0.19813 1.43308,0.67185 
1.50029,1.50028 l 1.99753,13.05863 -1.5003,0 -28.28862,0 -1.50029,0 c 0.66583,-4.35287 1.33168,-8.70575 
1.99751,-13.05863 0.12565,-0.82161 0.69519,-1.70681 1.50031,-1.50028 z"
+         id="path5220"
+         
style="fill:#ffffff;fill-opacity:1;stroke:#000000;stroke-width:1.59999979;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+         sodipodi:nodetypes="sssccccss"
+         inkscape:connector-curvature="0" />
+      <path
+         d="m 679.0389,325.47627 c 5.53,1.4185 18.51387,1.4185 24.29357,0 0.80721,-0.19813 1.43308,0.67185 
1.50029,1.50028 l 1.99753,13.05863 -1.5003,0 -28.28862,0 -1.50029,0 c 0.66583,-4.35287 1.33168,-8.70575 
1.99751,-13.05863 0.12565,-0.82161 0.69521,-1.70681 1.50031,-1.50028 z"
+         id="path5222"
+         
style="fill:#ffffff;fill-opacity:1;stroke:#000000;stroke-width:1.59999979;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+         sodipodi:nodetypes="sssccccss"
+         inkscape:connector-curvature="0" />
+      <path
+         d="m 712.58289,325.47627 c 5.53002,1.4185 18.51389,1.4185 24.29359,0 0.80721,-0.19813 
1.43306,0.67185 1.50029,1.50028 l 1.99751,13.05863 -1.50028,0 -28.28862,0 -1.50029,0 c 0.66583,-4.35287 
1.33167,-8.70575 1.99751,-13.05863 0.12565,-0.82161 0.69519,-1.70681 1.50029,-1.50028 z"
+         id="path4829"
+         
style="fill:#ffffff;fill-opacity:1;stroke:#000000;stroke-width:1.60000002;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+         sodipodi:nodetypes="sssccccss"
+         inkscape:connector-curvature="0" />
+      <path
+         d="m 516.19044,335.26648 c 5.53002,1.41851 18.51389,1.41851 24.2936,0 0.8072,-0.19812 
1.43306,0.67186 1.50028,1.50029 l 1.99752,13.05863 -1.50029,0 -28.28862,0 -1.50029,0 c 0.66584,-4.35288 
1.33167,-8.70575 1.99752,-13.05863 0.12564,-0.82161 0.69518,-1.70681 1.50028,-1.50029 z"
+         id="path3662"
+         
style="fill:#ffffff;fill-opacity:1;stroke:#000000;stroke-width:1.59999979;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+         sodipodi:nodetypes="sssccccss"
+         inkscape:connector-curvature="0" />
+      <path
+         d="m 550.14827,335.26648 c 5.53,1.41851 18.51387,1.41851 24.29358,0 0.8072,-0.19812 1.43307,0.67186 
1.50028,1.50029 l 1.99753,13.05863 -1.5003,0 -28.28862,0 -1.50029,0 c 0.66584,-4.35288 1.33168,-8.70575 
1.99752,-13.05863 0.12564,-0.82161 0.69518,-1.70681 1.5003,-1.50029 z"
+         id="path3664"
+         
style="fill:#ffffff;fill-opacity:1;stroke:#000000;stroke-width:1.59999979;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+         sodipodi:nodetypes="sssccccss"
+         inkscape:connector-curvature="0" />
+      <path
+         d="m 584.10608,335.26648 c 5.53,1.41851 18.51387,1.41851 24.29357,0 0.80721,-0.19812 
1.43308,0.67186 1.50029,1.50029 l 1.99753,13.05863 -1.5003,0 -28.28862,0 -1.50029,0 c 0.66584,-4.35288 
1.33168,-8.70575 1.99752,-13.05863 0.12564,-0.82161 0.6952,-1.70681 1.5003,-1.50029 z"
+         id="path3666"
+         
style="fill:#ffffff;fill-opacity:1;stroke:#000000;stroke-width:1.59999979;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+         sodipodi:nodetypes="sssccccss"
+         inkscape:connector-curvature="0" />
+      <path
+         d="m 617.65007,335.26648 c 5.53002,1.41851 18.51389,1.41851 24.29359,0 0.80721,-0.19812 
1.43306,0.67186 1.50029,1.50029 l 1.99751,13.05863 -1.50028,0 -28.28862,0 -1.50029,0 c 0.66584,-4.35288 
1.33167,-8.70575 1.99752,-13.05863 0.12564,-0.82161 0.69518,-1.70681 1.50028,-1.50029 z"
+         id="path3668"
+         
style="fill:#ffffff;fill-opacity:1;stroke:#000000;stroke-width:1.59999979;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+         sodipodi:nodetypes="sssccccss"
+         inkscape:connector-curvature="0" />
+      <path
+         d="m 651.6079,335.26648 c 5.53,1.41851 18.51387,1.41851 24.29357,0 0.80721,-0.19812 1.43308,0.67186 
1.50029,1.50029 l 1.99753,13.05863 -1.5003,0 -28.28862,0 -1.50029,0 c 0.66583,-4.35288 1.33168,-8.70575 
1.99752,-13.05863 0.12564,-0.82161 0.69518,-1.70681 1.5003,-1.50029 z"
+         id="path3670"
+         
style="fill:#ffffff;fill-opacity:1;stroke:#000000;stroke-width:1.59999979;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+         sodipodi:nodetypes="sssccccss"
+         inkscape:connector-curvature="0" />
+      <path
+         d="m 685.56571,335.26648 c 5.53,1.41851 18.51387,1.41851 24.29357,0 0.80721,-0.19812 
1.43308,0.67186 1.50029,1.50029 l 1.99753,13.05863 -1.5003,0 -28.28862,0 -1.50029,0 c 0.66583,-4.35288 
1.33168,-8.70575 1.99752,-13.05863 0.12564,-0.82161 0.6952,-1.70681 1.5003,-1.50029 z"
+         id="path3672"
+         
style="fill:#ffffff;fill-opacity:1;stroke:#000000;stroke-width:1.59999979;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+         sodipodi:nodetypes="sssccccss"
+         inkscape:connector-curvature="0" />
+      <path
+         d="m 719.1097,335.26648 c 5.53002,1.41851 18.51389,1.41851 24.29359,0 0.80721,-0.19812 
1.43306,0.67186 1.50029,1.50029 l 1.99751,13.05863 -1.50028,0 -28.28862,0 -1.50029,0 c 0.66583,-4.35288 
1.33167,-8.70575 1.99752,-13.05863 0.12564,-0.82161 0.69518,-1.70681 1.50028,-1.50029 z"
+         id="path3674"
+         
style="fill:#ffffff;fill-opacity:1;stroke:#000000;stroke-width:1.60000002;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+         sodipodi:nodetypes="sssccccss"
+         inkscape:connector-curvature="0" />
+      <path
+         d="m 525.98066,345.0567 c 5.53002,1.4185 18.51389,1.4185 24.29359,0 0.80721,-0.19812 
1.43306,0.67185 1.50029,1.50029 l 1.99751,13.05863 -1.50028,0 -28.28862,0 -1.50029,0 c 0.66583,-4.35288 
1.33167,-8.70576 1.99752,-13.05863 0.12564,-0.82162 0.69518,-1.70681 1.50028,-1.50029 z"
+         id="path3676"
+         
style="fill:#ffffff;fill-opacity:1;stroke:#000000;stroke-width:1.59999979;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+         sodipodi:nodetypes="sssccccss"
+         inkscape:connector-curvature="0" />
+      <path
+         d="m 559.93849,345.0567 c 5.53,1.4185 18.51387,1.4185 24.29357,0 0.80721,-0.19812 1.43308,0.67185 
1.50029,1.50029 l 1.99753,13.05863 -1.5003,0 -28.28862,0 -1.50029,0 c 0.66583,-4.35288 1.33168,-8.70576 
1.99751,-13.05863 0.12565,-0.82162 0.69519,-1.70681 1.50031,-1.50029 z"
+         id="path3678"
+         
style="fill:#ffffff;fill-opacity:1;stroke:#000000;stroke-width:1.59999979;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+         sodipodi:nodetypes="sssccccss"
+         inkscape:connector-curvature="0" />
+      <path
+         d="m 593.8963,345.0567 c 5.53,1.4185 18.51387,1.4185 24.29357,0 0.80721,-0.19812 1.43308,0.67185 
1.50029,1.50029 l 1.99753,13.05863 -1.5003,0 -28.28862,0 -1.50029,0 c 0.66583,-4.35288 1.33168,-8.70576 
1.99751,-13.05863 0.12565,-0.82162 0.69521,-1.70681 1.50031,-1.50029 z"
+         id="path3680"
+         
style="fill:#ffffff;fill-opacity:1;stroke:#000000;stroke-width:1.59999979;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+         sodipodi:nodetypes="sssccccss"
+         inkscape:connector-curvature="0" />
+      <path
+         d="m 627.44029,345.0567 c 5.53002,1.4185 18.51389,1.4185 24.29359,0 0.80721,-0.19812 
1.43306,0.67185 1.50029,1.50029 l 1.99751,13.05863 -1.50028,0 -28.28862,0 -1.50029,0 c 0.66583,-4.35288 
1.33167,-8.70576 1.99751,-13.05863 0.12565,-0.82162 0.69519,-1.70681 1.50029,-1.50029 z"
+         id="path3682"
+         
style="fill:#ffffff;fill-opacity:1;stroke:#000000;stroke-width:1.59999979;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+         sodipodi:nodetypes="sssccccss"
+         inkscape:connector-curvature="0" />
+      <path
+         d="m 661.39812,345.0567 c 5.53,1.4185 18.51387,1.4185 24.29357,0 0.80721,-0.19812 1.43308,0.67185 
1.50029,1.50029 l 1.99753,13.05863 -1.5003,0 -28.28863,0 -1.50028,0 c 0.66583,-4.35288 1.33168,-8.70576 
1.99751,-13.05863 0.12564,-0.82162 0.69519,-1.70681 1.50031,-1.50029 z"
+         id="path3684"
+         
style="fill:#ffffff;fill-opacity:1;stroke:#000000;stroke-width:1.59999979;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+         sodipodi:nodetypes="sssccccss"
+         inkscape:connector-curvature="0" />
+      <path
+         d="m 695.35593,345.0567 c 5.53,1.4185 18.51387,1.4185 24.29357,0 0.8072,-0.19812 1.43308,0.67185 
1.50029,1.50029 l 1.99753,13.05863 -1.5003,0 -28.28863,0 -1.50028,0 c 0.66583,-4.35288 1.33168,-8.70576 
1.99751,-13.05863 0.12564,-0.82162 0.69521,-1.70681 1.50031,-1.50029 z"
+         id="path3686"
+         
style="fill:#ffffff;fill-opacity:1;stroke:#000000;stroke-width:1.59999979;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+         sodipodi:nodetypes="sssccccss"
+         inkscape:connector-curvature="0" />
+      <path
+         d="m 728.89992,345.0567 c 5.53002,1.4185 18.51389,1.4185 24.29359,0 0.8072,-0.19812 1.43306,0.67185 
1.50029,1.50029 l 1.99751,13.05863 -1.50028,0 -28.28863,0 -1.50028,0 c 0.66583,-4.35288 1.33166,-8.70576 
1.99751,-13.05863 0.12564,-0.82162 0.69519,-1.70681 1.50029,-1.50029 z"
+         id="path3688"
+         
style="fill:#ffffff;fill-opacity:1;stroke:#000000;stroke-width:1.60000002;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+         sodipodi:nodetypes="sssccccss"
+         inkscape:connector-curvature="0" />
+      <g
+         id="g3713"
+         transform="matrix(1.359752,0,0,1.359752,418.09336,-671.08525)">
+        <path
+           d="m 95.250257,715.10933 0,1.09089 c -1.31e-4,0.0113 -5.02e-4,0.0227 0,0.0341 0.01222,0.27812 
0.140266,0.55621 0.340902,0.74999 l 5.693061,5.76124 5.65897,-5.76124 c 0.20529,-0.20532 0.30681,-0.49473 
0.30681,-0.78413 l 0,-1.09089 -1.09088,0 c -0.28941,0 -0.57881,0.10156 -0.78408,0.30681 l -4.09082,4.15901 
-4.124913,-4.15901 c -0.212319,-0.22989 -0.511898,-0.33071 -0.818164,-0.30681 l -1.090886,0 z"
+           id="path3715"
+           
style="color:#bebebe;font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:medium;line-height:normal;font-family:'Andale
 Mono';-inkscape-font-specification:'Andale 
Mono';text-indent:0;text-align:start;text-decoration:none;text-decoration-line:none;letter-spacing:normal;word-spacing:normal;text-transform:none;direction:ltr;block-progression:tb;writing-mode:lr-tb;text-anchor:start;display:inline;overflow:visible;visibility:visible;fill:#000100;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:1.78124988;marker:none;enable-background:new"
+           inkscape:connector-curvature="0" />
+        <rect
+           height="11.999745"
+           id="rect3717"
+           rx="0"
+           ry="0"
+           
style="color:#bebebe;display:inline;overflow:visible;visibility:visible;fill:#000100;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:2;marker:none;enable-background:accumulate"
+           transform="scale(-1,1)"
+           width="2.1817718"
+           x="-102.34102"
+           y="708.92743" />
+      </g>
+      <g
+         id="g3740"
+         transform="matrix(1.359752,0,0,1.359752,492.12198,-661.29504)">
+        <path
+           d="m 95.250257,715.10933 0,1.09089 c -1.31e-4,0.0113 -5.02e-4,0.0227 0,0.0341 0.01222,0.27812 
0.140266,0.55621 0.340902,0.74999 l 5.693061,5.76124 5.65897,-5.76124 c 0.20529,-0.20532 0.30681,-0.49473 
0.30681,-0.78413 l 0,-1.09089 -1.09088,0 c -0.28941,0 -0.57881,0.10156 -0.78408,0.30681 l -4.09082,4.15901 
-4.124913,-4.15901 c -0.212319,-0.22989 -0.511898,-0.33071 -0.818164,-0.30681 l -1.090886,0 z"
+           id="path3742"
+           
style="color:#bebebe;font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:medium;line-height:normal;font-family:'Andale
 Mono';-inkscape-font-specification:'Andale 
Mono';text-indent:0;text-align:start;text-decoration:none;text-decoration-line:none;letter-spacing:normal;word-spacing:normal;text-transform:none;direction:ltr;block-progression:tb;writing-mode:lr-tb;text-anchor:start;display:inline;overflow:visible;visibility:visible;fill:#000100;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:1.78124988;marker:none;enable-background:new"
+           inkscape:connector-curvature="0" />
+        <rect
+           height="11.999745"
+           id="rect3744"
+           rx="0"
+           ry="0"
+           
style="color:#bebebe;display:inline;overflow:visible;visibility:visible;fill:#000100;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:2;marker:none;enable-background:accumulate"
+           transform="scale(-1,1)"
+           width="2.1817718"
+           x="-102.34102"
+           y="708.92743" />
+      </g>
+      <g
+         id="g3746"
+         transform="matrix(1.359752,0,0,1.359752,593.58161,-661.29504)">
+        <path
+           d="m 95.250257,715.10933 0,1.09089 c -1.31e-4,0.0113 -5.02e-4,0.0227 0,0.0341 0.01222,0.27812 
0.140266,0.55621 0.340902,0.74999 l 5.693061,5.76124 5.65897,-5.76124 c 0.20529,-0.20532 0.30681,-0.49473 
0.30681,-0.78413 l 0,-1.09089 -1.09088,0 c -0.28941,0 -0.57881,0.10156 -0.78408,0.30681 l -4.09082,4.15901 
-4.124913,-4.15901 c -0.212319,-0.22989 -0.511898,-0.33071 -0.818164,-0.30681 l -1.090886,0 z"
+           id="path3748"
+           
style="color:#bebebe;font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:medium;line-height:normal;font-family:'Andale
 Mono';-inkscape-font-specification:'Andale 
Mono';text-indent:0;text-align:start;text-decoration:none;text-decoration-line:none;letter-spacing:normal;word-spacing:normal;text-transform:none;direction:ltr;block-progression:tb;writing-mode:lr-tb;text-anchor:start;display:inline;overflow:visible;visibility:visible;fill:#000100;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:1.78124988;marker:none;enable-background:new"
+           inkscape:connector-curvature="0" />
+        <rect
+           height="11.999745"
+           id="rect3750"
+           rx="0"
+           ry="0"
+           
style="color:#bebebe;display:inline;overflow:visible;visibility:visible;fill:#000100;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:2;marker:none;enable-background:accumulate"
+           transform="scale(-1,1)"
+           width="2.1817718"
+           x="-102.34102"
+           y="708.92743" />
+      </g>
+    </g>
+  </g>
+</svg>
diff --git a/src/libide/gtk/ide-animation.c b/src/libide/gtk/ide-animation.c
new file mode 100644
index 000000000..015bd7efc
--- /dev/null
+++ b/src/libide/gtk/ide-animation.c
@@ -0,0 +1,1156 @@
+/* ide-animation.c
+ *
+ * Copyright (C) 2010-2022 Christian Hergert <christian hergert me>
+ *
+ * This file is free software; you can redistribute it and/or modify it under
+ * the terms of the GNU Lesser General Public License as published by the Free
+ * Software Foundation; either version 2.1 of the License, or (at your option)
+ * any later version.
+ *
+ * This file is distributed in the hope that it will be useful, but WITHOUT
+ * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+ * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU Lesser General Public
+ * License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+#define G_LOG_DOMAIN "ide-animation"
+
+#include "config.h"
+
+#include <stdlib.h>
+#include <string.h>
+
+#include <glib/gi18n.h>
+#include <gobject/gvaluecollector.h>
+#include <gdk/gdk.h>
+#include <gtk/gtk.h>
+
+#include "ide-animation.h"
+#include "ide-frame-source-private.h"
+#include "ide-gtk-enums.h"
+
+#define FALLBACK_FRAME_RATE 60
+
+typedef gdouble (*AlphaFunc) (gdouble       offset);
+typedef void    (*TweenFunc) (const GValue *begin,
+                              const GValue *end,
+                              GValue       *value,
+                              gdouble       offset);
+
+typedef struct
+{
+  gboolean    is_child;  /* Does GParamSpec belong to parent widget */
+  GParamSpec *pspec;     /* GParamSpec of target property */
+  GValue      begin;     /* Begin value in animation */
+  GValue      end;       /* End value in animation */
+} Tween;
+
+
+struct _IdeAnimation
+{
+  GInitiallyUnowned  parent_instance;
+
+  gpointer           target;              /* Target object to animate */
+  gint64             begin_time;          /* Time in which animation started */
+  gint64             end_time;            /* Deadline for the animation */
+  guint              duration_msec;       /* Duration in milliseconds */
+  guint              mode;                /* Tween mode */
+  gulong             tween_handler;       /* GSource or signal handler */
+  gulong             after_paint_handler; /* signal handler */
+  gdouble            last_offset;         /* Track our last offset */
+  GArray            *tweens;              /* Array of tweens to perform */
+  GdkFrameClock     *frame_clock;         /* An optional frame-clock for sync. */
+  GDestroyNotify     notify;              /* Notify callback */
+  gpointer           notify_data;         /* Data for notify */
+  guint              stop_called : 1;
+};
+
+G_DEFINE_TYPE (IdeAnimation, ide_animation, G_TYPE_INITIALLY_UNOWNED)
+
+enum {
+  PROP_0,
+  PROP_DURATION,
+  PROP_FRAME_CLOCK,
+  PROP_MODE,
+  PROP_TARGET,
+  LAST_PROP
+};
+
+
+enum {
+  TICK,
+  LAST_SIGNAL
+};
+
+
+/*
+ * Helper macros.
+ */
+#define LAST_FUNDAMENTAL 64
+#define TWEEN(type)                                       \
+  static void                                             \
+  tween_ ## type (const GValue * begin,                   \
+                  const GValue * end,                     \
+                  GValue * value,                         \
+                  gdouble offset)                         \
+  {                                                       \
+    g ## type x = g_value_get_ ## type (begin);           \
+    g ## type y = g_value_get_ ## type (end);             \
+    g_value_set_ ## type (value, x + ((y - x) * offset)); \
+  }
+
+
+/*
+ * Globals.
+ */
+static AlphaFunc   alpha_funcs[IDE_ANIMATION_LAST];
+static gboolean    debug;
+static GParamSpec *properties[LAST_PROP];
+static guint       signals[LAST_SIGNAL];
+static TweenFunc   tween_funcs[LAST_FUNDAMENTAL];
+static guint       slow_down_factor = 1;
+
+
+/*
+ * Tweeners for basic types.
+ */
+TWEEN (int);
+TWEEN (uint);
+TWEEN (long);
+TWEEN (ulong);
+TWEEN (float);
+TWEEN (double);
+
+
+/**
+ * ide_animation_alpha_ease_in_cubic:
+ * @offset: (in): The position within the animation; 0.0 to 1.0.
+ *
+ * An alpha function to transform the offset within the animation.
+ * @IDE_ANIMATION_CUBIC means the valu ewill be transformed into
+ * cubic acceleration (x * x * x).
+ */
+static gdouble
+ide_animation_alpha_ease_in_cubic (gdouble offset)
+{
+  return offset * offset * offset;
+}
+
+
+static gdouble
+ide_animation_alpha_ease_out_cubic (gdouble offset)
+{
+  gdouble p = offset - 1.0;
+
+  return p * p * p + 1.0;
+}
+
+static gdouble
+ide_animation_alpha_ease_in_out_cubic (gdouble offset)
+{
+  gdouble p = offset * 2.0;
+
+  if (p < 1.0)
+    return 0.5 * p * p * p;
+  p -= 2.0;
+  return 0.5 * (p * p * p + 2.0);
+}
+
+
+/**
+ * ide_animation_alpha_linear:
+ * @offset: (in): The position within the animation; 0.0 to 1.0.
+ *
+ * An alpha function to transform the offset within the animation.
+ * @IDE_ANIMATION_LINEAR means no tranformation will be made.
+ *
+ * Returns: @offset.
+ * Side effects: None.
+ */
+static gdouble
+ide_animation_alpha_linear (gdouble offset)
+{
+  return offset;
+}
+
+
+/**
+ * ide_animation_alpha_ease_in_quad:
+ * @offset: (in): The position within the animation; 0.0 to 1.0.
+ *
+ * An alpha function to transform the offset within the animation.
+ * @IDE_ANIMATION_EASE_IN_QUAD means that the value will be transformed
+ * into a quadratic acceleration.
+ *
+ * Returns: A tranformation of @offset.
+ * Side effects: None.
+ */
+static gdouble
+ide_animation_alpha_ease_in_quad (gdouble offset)
+{
+  return offset * offset;
+}
+
+
+/**
+ * ide_animation_alpha_ease_out_quad:
+ * @offset: (in): The position within the animation; 0.0 to 1.0.
+ *
+ * An alpha function to transform the offset within the animation.
+ * @IDE_ANIMATION_EASE_OUT_QUAD means that the value will be transformed
+ * into a quadratic deceleration.
+ *
+ * Returns: A tranformation of @offset.
+ * Side effects: None.
+ */
+static gdouble
+ide_animation_alpha_ease_out_quad (gdouble offset)
+{
+  return -1.0 * offset * (offset - 2.0);
+}
+
+
+/**
+ * ide_animation_alpha_ease_in_out_quad:
+ * @offset: (in): The position within the animation; 0.0 to 1.0.
+ *
+ * An alpha function to transform the offset within the animation.
+ * @IDE_ANIMATION_EASE_IN_OUT_QUAD means that the value will be transformed
+ * into a quadratic acceleration for the first half, and quadratic
+ * deceleration the second half.
+ *
+ * Returns: A tranformation of @offset.
+ * Side effects: None.
+ */
+static gdouble
+ide_animation_alpha_ease_in_out_quad (gdouble offset)
+{
+  offset *= 2.0;
+  if (offset < 1.0)
+    return 0.5 * offset * offset;
+  offset -= 1.0;
+  return -0.5 * (offset * (offset - 2.0) - 1.0);
+}
+
+
+/**
+ * ide_animation_load_begin_values:
+ * @animation: (in): A #IdeAnimation.
+ *
+ * Load the begin values for all the properties we are about to
+ * animate.
+ *
+ * Side effects: None.
+ */
+static void
+ide_animation_load_begin_values (IdeAnimation *animation)
+{
+  g_assert (IDE_IS_ANIMATION (animation));
+
+  for (guint i = 0; i < animation->tweens->len; i++)
+    {
+      Tween *tween = &g_array_index (animation->tweens, Tween, i);
+
+      g_value_reset (&tween->begin);
+      g_object_get_property (animation->target,
+                             tween->pspec->name,
+                             &tween->begin);
+    }
+}
+
+
+/**
+ * ide_animation_unload_begin_values:
+ * @animation: (in): A #IdeAnimation.
+ *
+ * Unloads the begin values for the animation. This might be particularly
+ * useful once we support pointer types.
+ *
+ * Side effects: None.
+ */
+static void
+ide_animation_unload_begin_values (IdeAnimation *animation)
+{
+  Tween *tween;
+  guint i;
+
+  g_assert (IDE_IS_ANIMATION (animation));
+
+  for (i = 0; i < animation->tweens->len; i++)
+    {
+      tween = &g_array_index (animation->tweens, Tween, i);
+      g_value_reset (&tween->begin);
+    }
+}
+
+
+/**
+ * ide_animation_get_offset:
+ * @animation: A #IdeAnimation.
+ * @frame_time: the time to present the frame, or 0 for current timing.
+ *
+ * Retrieves the position within the animation from 0.0 to 1.0. This
+ * value is calculated using the msec of the beginning of the animation
+ * and the current time.
+ *
+ * Returns: The offset of the animation from 0.0 to 1.0.
+ */
+static gdouble
+ide_animation_get_offset (IdeAnimation *animation,
+                          gint64        frame_time)
+{
+  g_assert (IDE_IS_ANIMATION (animation));
+
+  if (frame_time == 0)
+    {
+      if (animation->frame_clock != NULL)
+        frame_time = gdk_frame_clock_get_frame_time (animation->frame_clock);
+      else
+        frame_time = g_get_monotonic_time ();
+    }
+
+  frame_time = CLAMP (frame_time, animation->begin_time, animation->end_time);
+
+  /* Check end_time first in case end_time == begin_time */
+  if (frame_time == animation->end_time)
+    return 1.0;
+  else if (frame_time == animation->begin_time)
+    return 0.0;
+
+  return (frame_time - animation->begin_time) / (gdouble)(animation->duration_msec * 1000L);
+}
+
+
+/**
+ * ide_animation_update_property:
+ * @animation: (in): A #IdeAnimation.
+ * @target: (in): A #GObject.
+ * @tween: (in): a #Tween containing the property.
+ * @value: (in): The new value for the property.
+ *
+ * Updates the value of a property on an object using @value.
+ *
+ * Side effects: The property of @target is updated.
+ */
+static void
+ide_animation_update_property (IdeAnimation  *animation,
+                              gpointer      target,
+                              Tween        *tween,
+                              const GValue *value)
+{
+  g_assert (IDE_IS_ANIMATION (animation));
+  g_assert (G_IS_OBJECT (target));
+  g_assert (tween);
+  g_assert (value);
+
+  g_object_set_property (target, tween->pspec->name, value);
+}
+
+
+/**
+ * ide_animation_get_value_at_offset:
+ * @animation: (in): A #IdeAnimation.
+ * @offset: (in): The offset in the animation from 0.0 to 1.0.
+ * @tween: (in): A #Tween containing the property.
+ * @value: (out): A #GValue in which to store the property.
+ *
+ * Retrieves a value for a particular position within the animation.
+ *
+ * Side effects: None.
+ */
+static void
+ide_animation_get_value_at_offset (IdeAnimation *animation,
+                                   gdouble       offset,
+                                   Tween        *tween,
+                                   GValue       *value)
+{
+  g_assert (IDE_IS_ANIMATION (animation));
+  g_assert (tween != NULL);
+  g_assert (value != NULL);
+  g_assert (value->g_type == tween->pspec->value_type);
+
+  if (value->g_type < LAST_FUNDAMENTAL)
+    {
+      /*
+       * If you hit the following assertion, you need to add a function
+       * to create the new value at the given offset.
+       */
+      g_assert (tween_funcs[value->g_type]);
+      tween_funcs[value->g_type](&tween->begin, &tween->end, value, offset);
+    }
+  else
+    {
+      /*
+       * TODO: Support complex transitions.
+       */
+      if (offset >= 1.0)
+        g_value_copy (&tween->end, value);
+    }
+}
+
+static void
+ide_animation_set_frame_clock (IdeAnimation  *animation,
+                               GdkFrameClock *frame_clock)
+{
+  if (animation->frame_clock != frame_clock)
+    {
+      g_clear_object (&animation->frame_clock);
+      animation->frame_clock = frame_clock ? g_object_ref (frame_clock) : NULL;
+    }
+}
+
+static void
+ide_animation_set_target (IdeAnimation *animation,
+                          gpointer      target)
+{
+  g_assert (!animation->target);
+
+  animation->target = g_object_ref (target);
+
+  if (GTK_IS_WIDGET (animation->target))
+    ide_animation_set_frame_clock (animation,
+                                  gtk_widget_get_frame_clock (animation->target));
+}
+
+
+/**
+ * ide_animation_tick:
+ * @animation: (in): A #IdeAnimation.
+ *
+ * Moves the object properties to the next position in the animation.
+ *
+ * Returns: %TRUE if the animation has not completed; otherwise %FALSE.
+ * Side effects: None.
+ */
+static gboolean
+ide_animation_tick (IdeAnimation *animation,
+                    gdouble       offset)
+{
+  gdouble alpha;
+  GValue value = { 0 };
+  Tween *tween;
+  guint i;
+
+  g_assert (IDE_IS_ANIMATION (animation));
+
+  if (offset == animation->last_offset)
+    return offset < 1.0;
+
+  alpha = alpha_funcs[animation->mode](offset);
+
+  /*
+   * Update property values.
+   */
+  for (i = 0; i < animation->tweens->len; i++)
+    {
+      tween = &g_array_index (animation->tweens, Tween, i);
+      g_value_init (&value, tween->pspec->value_type);
+      ide_animation_get_value_at_offset (animation, alpha, tween, &value);
+      ide_animation_update_property (animation,
+                                     animation->target,
+                                     tween,
+                                     &value);
+      g_value_unset (&value);
+    }
+
+  /*
+   * Notify anyone interested in the tick signal.
+   */
+  g_signal_emit (animation, signals[TICK], 0);
+
+  animation->last_offset = offset;
+
+  return offset < 1.0;
+}
+
+
+/**
+ * ide_animation_timeout_cb:
+ * @user_data: (in): A #IdeAnimation.
+ *
+ * Timeout from the main loop to move to the next step of the animation.
+ *
+ * Returns: %TRUE until the animation has completed; otherwise %FALSE.
+ * Side effects: None.
+ */
+static gboolean
+ide_animation_timeout_cb (gpointer user_data)
+{
+  IdeAnimation *animation = user_data;
+  gboolean ret;
+  gdouble offset;
+
+  offset = ide_animation_get_offset (animation, 0);
+
+  if (!(ret = ide_animation_tick (animation, offset)))
+    ide_animation_stop (animation);
+
+  return ret;
+}
+
+
+static gboolean
+ide_animation_widget_tick_cb (GdkFrameClock *frame_clock,
+                              IdeAnimation  *animation)
+{
+  gboolean ret = G_SOURCE_REMOVE;
+
+  g_assert (GDK_IS_FRAME_CLOCK (frame_clock));
+  g_assert (IDE_IS_ANIMATION (animation));
+
+  if (animation->tween_handler)
+    {
+      gdouble offset;
+
+      offset = ide_animation_get_offset (animation, 0);
+
+      if (!(ret = ide_animation_tick (animation, offset)))
+        ide_animation_stop (animation);
+    }
+
+  return ret;
+}
+
+
+static void
+ide_animation_widget_after_paint_cb (GdkFrameClock *frame_clock,
+                                     IdeAnimation  *animation)
+{
+  gint64 base_time;
+  gint64 interval;
+  gint64 next_frame_time;
+  gdouble offset;
+
+  g_assert (GDK_IS_FRAME_CLOCK (frame_clock));
+  g_assert (IDE_IS_ANIMATION (animation));
+
+  base_time = gdk_frame_clock_get_frame_time (frame_clock);
+  gdk_frame_clock_get_refresh_info (frame_clock, base_time, &interval, &next_frame_time);
+
+  offset = ide_animation_get_offset (animation, next_frame_time);
+
+  ide_animation_tick (animation, offset);
+}
+
+
+/**
+ * ide_animation_start:
+ * @animation: (in): A #IdeAnimation.
+ *
+ * Start the animation. When the animation stops, the internal reference will
+ * be dropped and the animation may be finalized.
+ *
+ * Side effects: None.
+ */
+void
+ide_animation_start (IdeAnimation *animation)
+{
+  g_return_if_fail (IDE_IS_ANIMATION (animation));
+  g_return_if_fail (!animation->tween_handler);
+
+  g_object_ref_sink (animation);
+  ide_animation_load_begin_values (animation);
+
+  /*
+   * We want the real current time instead of the GdkFrameClocks current time
+   * because if the clock was asleep, it could be innaccurate.
+   */
+
+  if (animation->frame_clock)
+    {
+      animation->begin_time = gdk_frame_clock_get_frame_time (animation->frame_clock);
+      animation->end_time = animation->begin_time + (animation->duration_msec * 1000L);
+      animation->tween_handler =
+        g_signal_connect_object (animation->frame_clock,
+                                 "update",
+                                 G_CALLBACK (ide_animation_widget_tick_cb),
+                                 animation,
+                                 0);
+      animation->after_paint_handler =
+        g_signal_connect_object (animation->frame_clock,
+                                 "after-paint",
+                                 G_CALLBACK (ide_animation_widget_after_paint_cb),
+                                 animation,
+                                 0);
+      gdk_frame_clock_begin_updating (animation->frame_clock);
+    }
+  else
+    {
+      animation->begin_time = g_get_monotonic_time ();
+      animation->end_time = animation->begin_time + (animation->duration_msec * 1000L);
+      animation->tween_handler = ide_frame_source_add (FALLBACK_FRAME_RATE,
+                                                       ide_animation_timeout_cb,
+                                                       animation);
+    }
+}
+
+
+static void
+ide_animation_notify (IdeAnimation *self)
+{
+  g_assert (IDE_IS_ANIMATION (self));
+
+  if (self->notify != NULL)
+    {
+      GDestroyNotify notify = self->notify;
+      gpointer data = self->notify_data;
+
+      self->notify = NULL;
+      self->notify_data = NULL;
+
+      notify (data);
+    }
+}
+
+
+/**
+ * ide_animation_stop:
+ * @animation: (nullable): A #IdeAnimation.
+ *
+ * Stops a running animation. The internal reference to the animation is
+ * dropped and therefore may cause the object to finalize.
+ *
+ * As a convenience, this function accepts %NULL for @animation but
+ * does nothing if that should occur.
+ */
+void
+ide_animation_stop (IdeAnimation *animation)
+{
+  if (animation == NULL)
+    return;
+
+  g_return_if_fail (IDE_IS_ANIMATION (animation));
+
+  if (animation->stop_called)
+    return;
+
+  animation->stop_called = TRUE;
+
+  if (animation->tween_handler)
+    {
+      if (animation->frame_clock)
+        {
+          gdk_frame_clock_end_updating (animation->frame_clock);
+          g_signal_handler_disconnect (animation->frame_clock, animation->tween_handler);
+          g_signal_handler_disconnect (animation->frame_clock, animation->after_paint_handler);
+          animation->tween_handler = 0;
+        }
+      else
+        {
+          g_source_remove (animation->tween_handler);
+          animation->tween_handler = 0;
+        }
+      ide_animation_unload_begin_values (animation);
+      ide_animation_notify (animation);
+      g_object_unref (animation);
+    }
+}
+
+
+/**
+ * ide_animation_add_property:
+ * @animation: (in): A #IdeAnimation.
+ * @pspec: (in): A #ParamSpec of @target or a #GtkWidget<!-- -->'s parent.
+ * @value: (in): The new value for the property at the end of the animation.
+ *
+ * Adds a new property to the set of properties to be animated during the
+ * lifetime of the animation.
+ *
+ * Side effects: None.
+ */
+void
+ide_animation_add_property (IdeAnimation *animation,
+                            GParamSpec   *pspec,
+                            const GValue *value)
+{
+  Tween tween = { 0 };
+  GType type;
+
+  g_return_if_fail (IDE_IS_ANIMATION (animation));
+  g_return_if_fail (pspec != NULL);
+  g_return_if_fail (value != NULL);
+  g_return_if_fail (value->g_type);
+  g_return_if_fail (animation->target);
+  g_return_if_fail (!animation->tween_handler);
+
+  type = G_TYPE_FROM_INSTANCE (animation->target);
+  tween.is_child = !g_type_is_a (type, pspec->owner_type);
+  if (tween.is_child)
+    {
+      if (!GTK_IS_WIDGET (animation->target))
+        {
+          g_critical (_("Cannot locate property %s in class %s"),
+                      pspec->name, g_type_name (type));
+          return;
+        }
+    }
+
+  tween.pspec = g_param_spec_ref (pspec);
+  g_value_init (&tween.begin, pspec->value_type);
+  g_value_init (&tween.end, pspec->value_type);
+  g_value_copy (value, &tween.end);
+  g_array_append_val (animation->tweens, tween);
+}
+
+
+/**
+ * ide_animation_dispose:
+ * @object: (in): A #IdeAnimation.
+ *
+ * Releases any object references the animation contains.
+ *
+ * Side effects: None.
+ */
+static void
+ide_animation_dispose (GObject *object)
+{
+  IdeAnimation *self = IDE_ANIMATION (object);
+
+  g_clear_object (&self->target);
+  g_clear_object (&self->frame_clock);
+
+  G_OBJECT_CLASS (ide_animation_parent_class)->dispose (object);
+}
+
+
+/**
+ * ide_animation_finalize:
+ * @object: (in): A #IdeAnimation.
+ *
+ * Finalizes the object and releases any resources allocated.
+ *
+ * Side effects: None.
+ */
+static void
+ide_animation_finalize (GObject *object)
+{
+  IdeAnimation *self = IDE_ANIMATION (object);
+  Tween *tween;
+  guint i;
+
+  for (i = 0; i < self->tweens->len; i++)
+    {
+      tween = &g_array_index (self->tweens, Tween, i);
+      g_value_unset (&tween->begin);
+      g_value_unset (&tween->end);
+      g_param_spec_unref (tween->pspec);
+    }
+
+  g_array_unref (self->tweens);
+
+  G_OBJECT_CLASS (ide_animation_parent_class)->finalize (object);
+}
+
+
+/**
+ * ide_animation_set_property:
+ * @object: (in): A #GObject.
+ * @prop_id: (in): The property identifier.
+ * @value: (in): The given property.
+ * @pspec: (in): A #ParamSpec.
+ *
+ * Set a given #GObject property.
+ */
+static void
+ide_animation_set_property (GObject      *object,
+                            guint         prop_id,
+                            const GValue *value,
+                            GParamSpec   *pspec)
+{
+  IdeAnimation *animation = IDE_ANIMATION (object);
+
+  switch (prop_id)
+    {
+    case PROP_DURATION:
+      animation->duration_msec = g_value_get_uint (value) * slow_down_factor;
+      break;
+
+    case PROP_FRAME_CLOCK:
+      ide_animation_set_frame_clock (animation, g_value_get_object (value));
+      break;
+
+    case PROP_MODE:
+      animation->mode = g_value_get_enum (value);
+      break;
+
+    case PROP_TARGET:
+      ide_animation_set_target (animation, g_value_get_object (value));
+      break;
+
+    default:
+      G_OBJECT_WARN_INVALID_PROPERTY_ID (object, prop_id, pspec);
+    }
+}
+
+
+/**
+ * ide_animation_class_init:
+ * @klass: (in): A #IdeAnimationClass.
+ *
+ * Initializes the GObjectClass.
+ *
+ * Side effects: Properties, signals, and vtables are initialized.
+ */
+static void
+ide_animation_class_init (IdeAnimationClass *klass)
+{
+  GObjectClass *object_class;
+  const gchar *slow_down_factor_env;
+
+  debug = !!g_getenv ("IDE_ANIMATION_DEBUG");
+  slow_down_factor_env = g_getenv ("IDE_ANIMATION_SLOW_DOWN_FACTOR");
+
+  if (slow_down_factor_env)
+    slow_down_factor = MAX (1, atoi (slow_down_factor_env));
+
+  object_class = G_OBJECT_CLASS (klass);
+  object_class->dispose = ide_animation_dispose;
+  object_class->finalize = ide_animation_finalize;
+  object_class->set_property = ide_animation_set_property;
+
+  /**
+   * IdeAnimation:duration:
+   *
+   * The "duration" property is the total number of milliseconds that the
+   * animation should run before being completed.
+   */
+  properties[PROP_DURATION] =
+    g_param_spec_uint ("duration",
+                       "Duration",
+                       "The duration of the animation",
+                       0,
+                       G_MAXUINT,
+                       250,
+                       (G_PARAM_WRITABLE |
+                        G_PARAM_CONSTRUCT_ONLY |
+                        G_PARAM_STATIC_STRINGS));
+
+  properties[PROP_FRAME_CLOCK] =
+    g_param_spec_object ("frame-clock",
+                         "Frame Clock",
+                         "An optional frame-clock to synchronize with.",
+                         GDK_TYPE_FRAME_CLOCK,
+                         (G_PARAM_WRITABLE |
+                          G_PARAM_CONSTRUCT_ONLY |
+                          G_PARAM_STATIC_STRINGS));
+
+  /**
+   * IdeAnimation:mode:
+   *
+   * The "mode" property is the Alpha function that should be used to
+   * determine the offset within the animation based on the current
+   * offset in the animations duration.
+   */
+  properties[PROP_MODE] =
+    g_param_spec_enum ("mode",
+                       "Mode",
+                       "The animation mode",
+                       IDE_TYPE_ANIMATION_MODE,
+                       IDE_ANIMATION_LINEAR,
+                       (G_PARAM_WRITABLE |
+                        G_PARAM_CONSTRUCT_ONLY |
+                        G_PARAM_STATIC_STRINGS));
+
+  /**
+   * IdeAnimation:target:
+   *
+   * The "target" property is the #GObject that should have its properties
+   * animated.
+   */
+  properties[PROP_TARGET] =
+    g_param_spec_object ("target",
+                         "Target",
+                         "The target of the animation",
+                         G_TYPE_OBJECT,
+                         (G_PARAM_WRITABLE |
+                          G_PARAM_CONSTRUCT_ONLY |
+                          G_PARAM_STATIC_STRINGS));
+
+  g_object_class_install_properties (object_class, LAST_PROP, properties);
+
+  /**
+   * IdeAnimation::tick:
+   *
+   * The "tick" signal is emitted on each frame in the animation.
+   */
+  signals[TICK] = g_signal_new ("tick",
+                                 IDE_TYPE_ANIMATION,
+                                 G_SIGNAL_RUN_FIRST,
+                                 0,
+                                 NULL, NULL, NULL,
+                                 G_TYPE_NONE,
+                                 0);
+
+#define SET_ALPHA(_T, _t) \
+  alpha_funcs[IDE_ANIMATION_ ## _T] = ide_animation_alpha_ ## _t
+
+  SET_ALPHA (LINEAR, linear);
+  SET_ALPHA (EASE_IN_QUAD, ease_in_quad);
+  SET_ALPHA (EASE_OUT_QUAD, ease_out_quad);
+  SET_ALPHA (EASE_IN_OUT_QUAD, ease_in_out_quad);
+  SET_ALPHA (EASE_IN_CUBIC, ease_in_cubic);
+  SET_ALPHA (EASE_OUT_CUBIC, ease_out_cubic);
+  SET_ALPHA (EASE_IN_OUT_CUBIC, ease_in_out_cubic);
+
+#define SET_TWEEN(_T, _t) \
+  G_STMT_START { \
+    guint idx = G_TYPE_ ## _T; \
+    tween_funcs[idx] = tween_ ## _t; \
+  } G_STMT_END
+
+  SET_TWEEN (INT, int);
+  SET_TWEEN (UINT, uint);
+  SET_TWEEN (LONG, long);
+  SET_TWEEN (ULONG, ulong);
+  SET_TWEEN (FLOAT, float);
+  SET_TWEEN (DOUBLE, double);
+}
+
+
+/**
+ * ide_animation_init:
+ * @animation: (in): A #IdeAnimation.
+ *
+ * Initializes the #IdeAnimation instance.
+ *
+ * Side effects: Everything.
+ */
+static void
+ide_animation_init (IdeAnimation *animation)
+{
+  animation->duration_msec = 250;
+  animation->mode = IDE_ANIMATION_EASE_IN_OUT_QUAD;
+  animation->tweens = g_array_new (FALSE, FALSE, sizeof (Tween));
+  animation->last_offset = -G_MINDOUBLE;
+}
+
+
+/**
+ * ide_object_animatev:
+ * @object: A #GObject.
+ * @mode: The animation mode.
+ * @duration_msec: The duration in milliseconds.
+ * @frame_clock: (nullable): The #GdkFrameClock to synchronize to.
+ * @first_property: The first property to animate.
+ * @args: A variadac list of arguments
+ *
+ * Returns: (transfer none): A #IdeAnimation.
+ */
+IdeAnimation *
+ide_object_animatev (gpointer          object,
+                     IdeAnimationMode  mode,
+                     guint             duration_msec,
+                     GdkFrameClock    *frame_clock,
+                     const gchar      *first_property,
+                     va_list           args)
+{
+  IdeAnimation *animation;
+  GObjectClass *klass;
+  const gchar *name;
+  GParamSpec *pspec;
+  GValue value = { 0 };
+  gchar *error = NULL;
+  gboolean enable_animations;
+
+  g_return_val_if_fail (first_property != NULL, NULL);
+  g_return_val_if_fail (mode < IDE_ANIMATION_LAST, NULL);
+
+  if ((frame_clock == NULL) && GTK_IS_WIDGET (object))
+    frame_clock = gtk_widget_get_frame_clock (GTK_WIDGET (object));
+
+  /*
+   * If we have a frame clock, then we must be in the gtk thread and we
+   * should check GtkSettings for disabled animations. If we are disabled,
+   * we will just make the timeout immediate.
+   */
+  if (frame_clock != NULL)
+    {
+      g_object_get (gtk_settings_get_default (),
+                    "gtk-enable-animations", &enable_animations,
+                    NULL);
+
+      if (enable_animations == FALSE)
+        duration_msec = 0;
+    }
+
+  name = first_property;
+  klass = G_OBJECT_GET_CLASS (object);
+  animation = g_object_new (IDE_TYPE_ANIMATION,
+                            "duration", duration_msec,
+                            "frame-clock", frame_clock,
+                            "mode", mode,
+                            "target", object,
+                            NULL);
+
+  do
+    {
+      /*
+       * First check for the property on the object. If that does not exist
+       * then check if the object has a parent and look at its child
+       * properties (if it's a GtkWidget).
+       */
+      if (!(pspec = g_object_class_find_property (klass, name)))
+        {
+          g_critical (_("Failed to find property %s in %s"),
+                      name, G_OBJECT_CLASS_NAME (klass));
+          goto failure;
+        }
+
+      g_value_init (&value, pspec->value_type);
+      G_VALUE_COLLECT (&value, args, 0, &error);
+      if (error != NULL)
+        {
+          g_critical (_("Failed to retrieve va_list value: %s"), error);
+          g_free (error);
+          goto failure;
+        }
+
+      ide_animation_add_property (animation, pspec, &value);
+      g_value_unset (&value);
+    }
+  while ((name = va_arg (args, const gchar *)));
+
+  ide_animation_start (animation);
+
+  return animation;
+
+failure:
+  g_object_ref_sink (animation);
+  g_object_unref (animation);
+  return NULL;
+}
+
+/**
+ * ide_object_animate:
+ * @object: (in): A #GObject.
+ * @mode: (in): The animation mode.
+ * @duration_msec: (in): The duration in milliseconds.
+ * @first_property: (in): The first property to animate.
+ *
+ * Animates the properties of @object. The can be set in a similar manner to g_object_set(). They
+ * will be animated from their current value to the target value over the time period.
+ *
+ * Return value: (transfer none): A #IdeAnimation.
+ * Side effects: None.
+ */
+IdeAnimation*
+ide_object_animate (gpointer        object,
+                    IdeAnimationMode mode,
+                    guint           duration_msec,
+                    GdkFrameClock  *frame_clock,
+                    const gchar    *first_property,
+                    ...)
+{
+  IdeAnimation *animation;
+  va_list args;
+
+  va_start (args, first_property);
+  animation = ide_object_animatev (object,
+                                   mode,
+                                   duration_msec,
+                                   frame_clock,
+                                   first_property,
+                                   args);
+  va_end (args);
+
+  return animation;
+}
+
+/**
+ * ide_object_animate_full:
+ *
+ * Return value: (transfer none): A #IdeAnimation.
+ */
+IdeAnimation*
+ide_object_animate_full (gpointer        object,
+                         IdeAnimationMode mode,
+                         guint           duration_msec,
+                         GdkFrameClock  *frame_clock,
+                         GDestroyNotify  notify,
+                         gpointer        notify_data,
+                         const gchar    *first_property,
+                         ...)
+{
+  IdeAnimation *animation;
+  va_list args;
+
+  va_start (args, first_property);
+  animation = ide_object_animatev (object,
+                                   mode,
+                                   duration_msec,
+                                   frame_clock,
+                                   first_property,
+                                   args);
+  va_end (args);
+
+  animation->notify = notify;
+  animation->notify_data = notify_data;
+
+  return animation;
+}
+
+guint
+ide_animation_calculate_duration (GdkMonitor *monitor,
+                                  gdouble     from_value,
+                                  gdouble     to_value)
+{
+  GdkRectangle geom;
+  gdouble distance_units;
+  gdouble distance_mm;
+  gdouble mm_per_frame;
+  gint height_mm;
+  gint refresh_rate;
+  gint n_frames;
+  guint ret;
+
+#define MM_PER_SECOND       (150.0)
+#define MIN_FRAMES_PER_ANIM (5)
+#define MAX_FRAMES_PER_ANIM (500)
+
+  g_assert (GDK_IS_MONITOR (monitor));
+  g_assert (from_value >= 0.0);
+  g_assert (to_value >= 0.0);
+
+  /*
+   * Get various monitor information we'll need to calculate the duration of
+   * the animation. We need the physical space of the monitor, the refresh
+   * rate, and geometry so that we can limit how many device units we will
+   * traverse per-frame of the animation. Failure to deal with the physical
+   * space results in jittery animations to the user.
+   *
+   * It would also be nice to take into account the acceleration curve so that
+   * we know the max amount of jump per frame, but that is getting into
+   * diminishing returns since we can just average it out.
+   */
+  height_mm = gdk_monitor_get_height_mm (monitor);
+  gdk_monitor_get_geometry (monitor, &geom);
+  refresh_rate = gdk_monitor_get_refresh_rate (monitor);
+  if (refresh_rate == 0)
+    refresh_rate = 60000;
+
+  /*
+   * The goal here is to determine the number of millimeters that we need to
+   * animate given a transition of distance_unit pixels. Since we are dealing
+   * with physical units (mm), we don't need to take into account the device
+   * scale underneath the widget. The equation comes out the same.
+   */
+
+  distance_units = ABS (from_value - to_value);
+  distance_mm = distance_units / (gdouble)geom.height * height_mm;
+  mm_per_frame = MM_PER_SECOND / (refresh_rate / 1000.0);
+  n_frames = (distance_mm / mm_per_frame) + 1;
+
+  ret = n_frames * (1000.0 / (refresh_rate / 1000.0));
+  ret = CLAMP (ret,
+               MIN_FRAMES_PER_ANIM * (1000000.0 / refresh_rate),
+               MAX_FRAMES_PER_ANIM * (1000000.0 / refresh_rate));
+
+  return ret;
+
+#undef MM_PER_SECOND
+#undef MIN_FRAMES_PER_ANIM
+#undef MAX_FRAMES_PER_ANIM
+}
diff --git a/src/libide/gtk/ide-animation.h b/src/libide/gtk/ide-animation.h
new file mode 100644
index 000000000..92a20eaa4
--- /dev/null
+++ b/src/libide/gtk/ide-animation.h
@@ -0,0 +1,85 @@
+/* ide-animation.h
+ *
+ * Copyright (C) 2010-2016 Christian Hergert <christian hergert me>
+ *
+ * This file is free software; you can redistribute it and/or modify it under
+ * the terms of the GNU Lesser General Public License as published by the Free
+ * Software Foundation; either version 2.1 of the License, or (at your option)
+ * any later version.
+ *
+ * This file is distributed in the hope that it will be useful, but WITHOUT
+ * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+ * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU Lesser General Public
+ * License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+#pragma once
+
+#if !defined (IDE_GTK_INSIDE) && !defined (IDE_GTK_COMPILATION)
+# error "Only <libide-gtk.h> can be included directly."
+#endif
+
+#include <gdk/gdk.h>
+
+#include <libide-core.h>
+
+G_BEGIN_DECLS
+
+#define IDE_TYPE_ANIMATION (ide_animation_get_type())
+
+IDE_AVAILABLE_IN_ALL
+G_DECLARE_FINAL_TYPE (IdeAnimation, ide_animation, IDE, ANIMATION, GInitiallyUnowned)
+
+typedef enum _IdeAnimationMode
+{
+  IDE_ANIMATION_LINEAR,
+  IDE_ANIMATION_EASE_IN_QUAD,
+  IDE_ANIMATION_EASE_OUT_QUAD,
+  IDE_ANIMATION_EASE_IN_OUT_QUAD,
+  IDE_ANIMATION_EASE_IN_CUBIC,
+  IDE_ANIMATION_EASE_OUT_CUBIC,
+  IDE_ANIMATION_EASE_IN_OUT_CUBIC,
+
+  IDE_ANIMATION_LAST
+} IdeAnimationMode;
+
+IDE_AVAILABLE_IN_ALL
+void          ide_animation_start              (IdeAnimation     *animation);
+IDE_AVAILABLE_IN_ALL
+void          ide_animation_stop               (IdeAnimation     *animation);
+IDE_AVAILABLE_IN_ALL
+void          ide_animation_add_property       (IdeAnimation     *animation,
+                                                GParamSpec       *pspec,
+                                                const GValue     *value);
+IDE_AVAILABLE_IN_ALL
+IdeAnimation *ide_object_animatev              (gpointer          object,
+                                                IdeAnimationMode  mode,
+                                                guint             duration_msec,
+                                                GdkFrameClock    *frame_clock,
+                                                const gchar      *first_property,
+                                                va_list           args);
+IDE_AVAILABLE_IN_ALL
+IdeAnimation* ide_object_animate               (gpointer          object,
+                                                IdeAnimationMode  mode,
+                                                guint             duration_msec,
+                                                GdkFrameClock    *frame_clock,
+                                                const gchar      *first_property,
+                                                ...) G_GNUC_NULL_TERMINATED;
+IDE_AVAILABLE_IN_ALL
+IdeAnimation* ide_object_animate_full          (gpointer          object,
+                                                IdeAnimationMode  mode,
+                                                guint             duration_msec,
+                                                GdkFrameClock    *frame_clock,
+                                                GDestroyNotify    notify,
+                                                gpointer          notify_data,
+                                                const gchar      *first_property,
+                                                ...) G_GNUC_NULL_TERMINATED;
+IDE_AVAILABLE_IN_ALL
+guint         ide_animation_calculate_duration (GdkMonitor       *monitor,
+                                                gdouble           from_value,
+                                                gdouble           to_value);
+
+G_END_DECLS
diff --git a/src/libide/gui/ide-cell-renderer-fancy.c b/src/libide/gtk/ide-cell-renderer-fancy.c
similarity index 90%
rename from src/libide/gui/ide-cell-renderer-fancy.c
rename to src/libide/gtk/ide-cell-renderer-fancy.c
index e7169e053..ea9861c00 100644
--- a/src/libide/gui/ide-cell-renderer-fancy.c
+++ b/src/libide/gtk/ide-cell-renderer-fancy.c
@@ -55,7 +55,6 @@ get_layout (IdeCellRendererFancy *self,
   PangoLayout *l;
   PangoAttrList *attrs;
   GtkStyleContext *style = gtk_widget_get_style_context (widget);
-  GtkStateFlags state = gtk_style_context_get_state (style);
   GdkRGBA rgba;
 
   l = gtk_widget_create_pango_layout (widget, text);
@@ -65,7 +64,7 @@ get_layout (IdeCellRendererFancy *self,
 
   attrs = pango_attr_list_new ();
 
-  gtk_style_context_get_color (style, state, &rgba);
+  gtk_style_context_get_color (style, &rgba);
   pango_attr_list_insert (attrs,
                           pango_attr_foreground_new (rgba.red * 65535,
                                                      rgba.green * 65535,
@@ -98,11 +97,11 @@ ide_cell_renderer_fancy_get_preferred_width (GtkCellRenderer *cell,
   IdeCellRendererFancy *self = (IdeCellRendererFancy *)cell;
   PangoLayout *body;
   PangoLayout *title;
-  gint body_width = 0;
-  gint title_width = 0;
-  gint dummy;
-  gint xpad;
-  gint ypad;
+  int body_width = 0;
+  int title_width = 0;
+  int dummy;
+  int xpad;
+  int ypad;
 
   if (min_width == NULL)
     min_width = &dummy;
@@ -206,22 +205,22 @@ ide_cell_renderer_fancy_get_preferred_height_for_width (GtkCellRenderer *cell,
 }
 
 static void
-ide_cell_renderer_fancy_render (GtkCellRenderer      *renderer,
-                                cairo_t              *cr,
-                                GtkWidget            *widget,
-                                const GdkRectangle   *bg_area,
-                                const GdkRectangle   *cell_area,
-                                GtkCellRendererState  flags)
+ide_cell_renderer_fancy_snapshot (GtkCellRenderer      *renderer,
+                                  GtkSnapshot          *snapshot,
+                                  GtkWidget            *widget,
+                                  const GdkRectangle   *bg_area,
+                                  const GdkRectangle   *cell_area,
+                                  GtkCellRendererState  flags)
 {
   IdeCellRendererFancy *self = (IdeCellRendererFancy *)renderer;
   PangoLayout *body;
   PangoLayout *title;
-  gint xpad;
-  gint ypad;
-  gint height;
+  GdkRGBA rgba;
+  int xpad;
+  int ypad;
+  int height;
 
   g_assert (IDE_IS_CELL_RENDERER_FANCY (self));
-  g_assert (cr != NULL);
   g_assert (GTK_IS_WIDGET (widget));
   g_assert (bg_area != NULL);
   g_assert (cell_area != NULL);
@@ -233,13 +232,20 @@ ide_cell_renderer_fancy_render (GtkCellRenderer      *renderer,
 
   pango_layout_set_width (title, (cell_area->width - (xpad * 2)) * PANGO_SCALE);
   pango_layout_set_width (body, (cell_area->width - (xpad * 2)) * PANGO_SCALE);
+  pango_layout_get_pixel_size (title, NULL, &height);
 
-  cairo_move_to (cr, cell_area->x + xpad, cell_area->y + ypad);
-  pango_cairo_show_layout (cr, title);
+  gtk_style_context_get_color (gtk_widget_get_style_context (widget), &rgba);
 
-  pango_layout_get_pixel_size (title, NULL, &height);
-  cairo_move_to (cr, cell_area->x + xpad, cell_area->y +ypad + + height + TITLE_SPACING);
-  pango_cairo_show_layout (cr, body);
+  gtk_snapshot_save (snapshot);
+  gtk_snapshot_translate (snapshot,
+                          &GRAPHENE_POINT_INIT (cell_area->x + xpad,
+                                                cell_area->y + ypad));
+  gtk_snapshot_append_layout (snapshot, title, &rgba);
+  gtk_snapshot_translate (snapshot,
+                          &GRAPHENE_POINT_INIT (0,
+                                                height + TITLE_SPACING));
+  gtk_snapshot_append_layout (snapshot, body, &rgba);
+  gtk_snapshot_restore (snapshot);
 
   g_object_unref (body);
   g_object_unref (title);
@@ -315,7 +321,7 @@ ide_cell_renderer_fancy_class_init (IdeCellRendererFancyClass *klass)
   cell_class->get_request_mode = ide_cell_renderer_fancy_get_request_mode;
   cell_class->get_preferred_width = ide_cell_renderer_fancy_get_preferred_width;
   cell_class->get_preferred_height_for_width = ide_cell_renderer_fancy_get_preferred_height_for_width;
-  cell_class->render = ide_cell_renderer_fancy_render;
+  cell_class->snapshot = ide_cell_renderer_fancy_snapshot;
 
   /* Note that we do not emit notify for these properties */
 
@@ -354,8 +360,6 @@ ide_cell_renderer_fancy_get_title (IdeCellRendererFancy *self)
  *
  * Like ide_cell_renderer_fancy_set_title() but takes ownership
  * of @title, saving a string copy.
- *
- * Since: 3.32
  */
 void
 ide_cell_renderer_fancy_take_title (IdeCellRendererFancy *self,
diff --git a/src/libide/gui/ide-cell-renderer-fancy.h b/src/libide/gtk/ide-cell-renderer-fancy.h
similarity index 86%
rename from src/libide/gui/ide-cell-renderer-fancy.h
rename to src/libide/gtk/ide-cell-renderer-fancy.h
index 2d768612f..4085b2a3e 100644
--- a/src/libide/gui/ide-cell-renderer-fancy.h
+++ b/src/libide/gtk/ide-cell-renderer-fancy.h
@@ -20,8 +20,8 @@
 
 #pragma once
 
-#if !defined (IDE_GUI_INSIDE) && !defined (IDE_GUI_COMPILATION)
-# error "Only <libide-gui.h> can be included directly."
+#if !defined (IDE_GTK_INSIDE) && !defined (IDE_GTK_COMPILATION)
+# error "Only <libide-gtk.h> can be included directly."
 #endif
 
 #include <gtk/gtk.h>
@@ -31,22 +31,22 @@ G_BEGIN_DECLS
 
 #define IDE_TYPE_CELL_RENDERER_FANCY (ide_cell_renderer_fancy_get_type())
 
-IDE_AVAILABLE_IN_3_32
+IDE_AVAILABLE_IN_ALL
 G_DECLARE_FINAL_TYPE (IdeCellRendererFancy, ide_cell_renderer_fancy, IDE, CELL_RENDERER_FANCY, 
GtkCellRenderer)
 
-IDE_AVAILABLE_IN_3_32
+IDE_AVAILABLE_IN_ALL
 GtkCellRenderer *ide_cell_renderer_fancy_new        (void);
-IDE_AVAILABLE_IN_3_32
+IDE_AVAILABLE_IN_ALL
 const gchar     *ide_cell_renderer_fancy_get_body   (IdeCellRendererFancy *self);
-IDE_AVAILABLE_IN_3_32
+IDE_AVAILABLE_IN_ALL
 const gchar     *ide_cell_renderer_fancy_get_title  (IdeCellRendererFancy *self);
-IDE_AVAILABLE_IN_3_32
+IDE_AVAILABLE_IN_ALL
 void             ide_cell_renderer_fancy_take_title (IdeCellRendererFancy *self,
                                                      gchar                *title);
-IDE_AVAILABLE_IN_3_32
+IDE_AVAILABLE_IN_ALL
 void             ide_cell_renderer_fancy_set_title  (IdeCellRendererFancy *self,
                                                      const gchar          *title);
-IDE_AVAILABLE_IN_3_32
+IDE_AVAILABLE_IN_ALL
 void             ide_cell_renderer_fancy_set_body   (IdeCellRendererFancy *self,
                                                      const gchar          *body);
 
diff --git a/src/libide/gtk/ide-entry-popover.c b/src/libide/gtk/ide-entry-popover.c
new file mode 100644
index 000000000..c9e011482
--- /dev/null
+++ b/src/libide/gtk/ide-entry-popover.c
@@ -0,0 +1,427 @@
+/* ide-entry-popover.c
+ *
+ * Copyright (C) 2015 Christian Hergert <christian hergert me>
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+#include "config.h"
+
+#include "ide-entry-popover.h"
+
+typedef struct
+{
+  GtkPopover parent_instance;
+
+  GtkLabel  *title;
+  GtkLabel  *message;
+  GtkEntry  *entry;
+  GtkButton *button;
+} IdeEntryPopoverPrivate;
+
+G_DEFINE_TYPE_WITH_PRIVATE (IdeEntryPopover, ide_entry_popover, GTK_TYPE_POPOVER)
+
+enum {
+  PROP_0,
+  PROP_BUTTON_TEXT,
+  PROP_MESSAGE,
+  PROP_READY,
+  PROP_TEXT,
+  PROP_TITLE,
+  LAST_PROP
+};
+
+enum {
+  ACTIVATE,
+  CHANGED,
+  INSERT_TEXT,
+  LAST_SIGNAL
+};
+
+static GParamSpec *properties [LAST_PROP];
+static guint signals [LAST_SIGNAL];
+
+const gchar *
+ide_entry_popover_get_button_text (IdeEntryPopover *self)
+{
+  IdeEntryPopoverPrivate *priv = ide_entry_popover_get_instance_private (self);
+
+  g_return_val_if_fail (IDE_IS_ENTRY_POPOVER (self), NULL);
+
+  return gtk_button_get_label (priv->button);
+}
+
+void
+ide_entry_popover_set_button_text (IdeEntryPopover *self,
+                                   const gchar     *button_text)
+{
+  IdeEntryPopoverPrivate *priv = ide_entry_popover_get_instance_private (self);
+
+  g_return_if_fail (IDE_IS_ENTRY_POPOVER (self));
+
+  gtk_button_set_label (priv->button, button_text);
+  g_object_notify_by_pspec (G_OBJECT (self), properties [PROP_BUTTON_TEXT]);
+}
+
+const gchar *
+ide_entry_popover_get_message (IdeEntryPopover *self)
+{
+  IdeEntryPopoverPrivate *priv = ide_entry_popover_get_instance_private (self);
+
+  g_return_val_if_fail (IDE_IS_ENTRY_POPOVER (self), NULL);
+
+  return gtk_label_get_text (priv->message);
+}
+
+void
+ide_entry_popover_set_message (IdeEntryPopover *self,
+                               const gchar     *message)
+{
+  IdeEntryPopoverPrivate *priv = ide_entry_popover_get_instance_private (self);
+
+  g_return_if_fail (IDE_IS_ENTRY_POPOVER (self));
+
+  gtk_label_set_label (priv->message, message);
+  g_object_notify_by_pspec (G_OBJECT (self), properties [PROP_MESSAGE]);
+}
+
+gboolean
+ide_entry_popover_get_ready (IdeEntryPopover *self)
+{
+  IdeEntryPopoverPrivate *priv = ide_entry_popover_get_instance_private (self);
+
+  g_return_val_if_fail (IDE_IS_ENTRY_POPOVER (self), FALSE);
+
+  return gtk_widget_get_sensitive (GTK_WIDGET (priv->button));
+}
+
+void
+ide_entry_popover_set_ready (IdeEntryPopover *self,
+                             gboolean         ready)
+{
+  IdeEntryPopoverPrivate *priv = ide_entry_popover_get_instance_private (self);
+
+  g_return_if_fail (IDE_IS_ENTRY_POPOVER (self));
+
+  gtk_widget_set_sensitive (GTK_WIDGET (priv->button), ready);
+  g_object_notify_by_pspec (G_OBJECT (self), properties [PROP_READY]);
+}
+
+const gchar *
+ide_entry_popover_get_text (IdeEntryPopover *self)
+{
+  IdeEntryPopoverPrivate *priv = ide_entry_popover_get_instance_private (self);
+
+  g_return_val_if_fail (IDE_IS_ENTRY_POPOVER (self), NULL);
+
+  return gtk_editable_get_text (GTK_EDITABLE (priv->entry));
+}
+
+void
+ide_entry_popover_set_text (IdeEntryPopover *self,
+                            const gchar     *text)
+{
+  IdeEntryPopoverPrivate *priv = ide_entry_popover_get_instance_private (self);
+
+  g_return_if_fail (IDE_IS_ENTRY_POPOVER (self));
+
+  gtk_editable_set_text (GTK_EDITABLE (priv->entry), text);
+  g_object_notify_by_pspec (G_OBJECT (self), properties [PROP_TEXT]);
+}
+
+const gchar *
+ide_entry_popover_get_title (IdeEntryPopover *self)
+{
+  IdeEntryPopoverPrivate *priv = ide_entry_popover_get_instance_private (self);
+
+  g_return_val_if_fail (IDE_IS_ENTRY_POPOVER (self), NULL);
+
+  return gtk_label_get_label (priv->title);
+}
+
+void
+ide_entry_popover_set_title (IdeEntryPopover *self,
+                             const gchar     *title)
+{
+  IdeEntryPopoverPrivate *priv = ide_entry_popover_get_instance_private (self);
+
+  g_return_if_fail (IDE_IS_ENTRY_POPOVER (self));
+
+  gtk_label_set_label (priv->title, title);
+  g_object_notify_by_pspec (G_OBJECT (self), properties [PROP_TITLE]);
+}
+
+static void
+ide_entry_popover_button_clicked (IdeEntryPopover *self,
+                                  GtkButton       *button)
+{
+  IdeEntryPopoverPrivate *priv = ide_entry_popover_get_instance_private (self);
+  const gchar *text;
+
+  g_assert (IDE_IS_ENTRY_POPOVER (self));
+  g_assert (GTK_IS_BUTTON (button));
+
+  text = gtk_editable_get_text (GTK_EDITABLE (priv->entry));
+  g_signal_emit (self, signals [ACTIVATE], 0, text);
+  gtk_popover_popdown (GTK_POPOVER (self));
+}
+
+static void
+ide_entry_popover_entry_activate (IdeEntryPopover *self,
+                                  GtkEntry        *entry)
+{
+  IdeEntryPopoverPrivate *priv = ide_entry_popover_get_instance_private (self);
+
+  g_assert (IDE_IS_ENTRY_POPOVER (self));
+  g_assert (GTK_IS_ENTRY (entry));
+
+  if (ide_entry_popover_get_ready (self))
+    gtk_widget_activate (GTK_WIDGET (priv->button));
+}
+
+static void
+ide_entry_popover_entry_changed (IdeEntryPopover *self,
+                                 GtkEntry        *entry)
+{
+  g_assert (IDE_IS_ENTRY_POPOVER (self));
+  g_assert (GTK_IS_ENTRY (entry));
+
+  g_signal_emit (self, signals [CHANGED], 0);
+}
+
+static void
+ide_entry_popover_entry_insert_text (IdeEntryPopover *self,
+                                     gchar           *new_text,
+                                     gint             new_text_length,
+                                     gint            *position,
+                                     GtkEntry        *entry)
+{
+  gboolean ret = GDK_EVENT_PROPAGATE;
+  guint pos;
+  guint n_chars;
+
+  g_assert (IDE_IS_ENTRY_POPOVER (self));
+  g_assert (new_text != NULL);
+  g_assert (position != NULL);
+
+  pos = *position;
+  n_chars = (new_text_length >= 0) ? new_text_length : g_utf8_strlen (new_text, -1);
+
+  g_signal_emit (self, signals [INSERT_TEXT], 0, pos, new_text, n_chars, &ret);
+
+  if (ret == GDK_EVENT_STOP)
+    g_signal_stop_emission_by_name (entry, "insert-text");
+}
+
+static void
+ide_entry_popover_get_property (GObject    *object,
+                                guint       prop_id,
+                                GValue     *value,
+                                GParamSpec *pspec)
+{
+  IdeEntryPopover *self = IDE_ENTRY_POPOVER (object);
+
+  switch (prop_id)
+    {
+    case PROP_BUTTON_TEXT:
+      g_value_set_string (value, ide_entry_popover_get_button_text (self));
+      break;
+
+    case PROP_MESSAGE:
+      g_value_set_string (value, ide_entry_popover_get_message (self));
+      break;
+
+    case PROP_READY:
+      g_value_set_boolean (value, ide_entry_popover_get_ready (self));
+      break;
+
+    case PROP_TEXT:
+      g_value_set_string (value, ide_entry_popover_get_text (self));
+      break;
+
+    case PROP_TITLE:
+      g_value_set_string (value, ide_entry_popover_get_title (self));
+      break;
+
+    default:
+      G_OBJECT_WARN_INVALID_PROPERTY_ID (object, prop_id, pspec);
+    }
+}
+
+static void
+ide_entry_popover_set_property (GObject      *object,
+                                guint         prop_id,
+                                const GValue *value,
+                                GParamSpec   *pspec)
+{
+  IdeEntryPopover *self = IDE_ENTRY_POPOVER (object);
+
+  switch (prop_id)
+    {
+    case PROP_BUTTON_TEXT:
+      ide_entry_popover_set_button_text (self, g_value_get_string (value));
+      break;
+
+    case PROP_MESSAGE:
+      ide_entry_popover_set_message (self, g_value_get_string (value));
+      break;
+
+    case PROP_READY:
+      ide_entry_popover_set_ready (self, g_value_get_boolean (value));
+      break;
+
+    case PROP_TEXT:
+      ide_entry_popover_set_text (self, g_value_get_string (value));
+      break;
+
+    case PROP_TITLE:
+      ide_entry_popover_set_title (self, g_value_get_string (value));
+      break;
+
+    default:
+      G_OBJECT_WARN_INVALID_PROPERTY_ID (object, prop_id, pspec);
+    }
+}
+
+static void
+ide_entry_popover_class_init (IdeEntryPopoverClass *klass)
+{
+  GObjectClass *object_class = G_OBJECT_CLASS (klass);
+  GtkWidgetClass *widget_class = GTK_WIDGET_CLASS (klass);
+
+  object_class->get_property = ide_entry_popover_get_property;
+  object_class->set_property = ide_entry_popover_set_property;
+
+  properties [PROP_BUTTON_TEXT] =
+    g_param_spec_string ("button-text",
+                         "Button Text",
+                         "Button Text",
+                         NULL,
+                         (G_PARAM_READWRITE | G_PARAM_STATIC_STRINGS));
+
+  properties [PROP_MESSAGE] =
+    g_param_spec_string ("message",
+                         "Message",
+                         "Message",
+                         NULL,
+                         (G_PARAM_READWRITE | G_PARAM_STATIC_STRINGS));
+
+  properties [PROP_READY] =
+    g_param_spec_boolean ("ready",
+                          "Ready",
+                          "Ready",
+                          FALSE,
+                          (G_PARAM_READWRITE | G_PARAM_STATIC_STRINGS));
+
+  properties [PROP_TEXT] =
+    g_param_spec_string ("text",
+                         "Text",
+                         "Text",
+                         NULL,
+                         (G_PARAM_READWRITE | G_PARAM_STATIC_STRINGS));
+
+  properties [PROP_TITLE] =
+    g_param_spec_string ("title",
+                         "Title",
+                         "Title",
+                         NULL,
+                         (G_PARAM_READWRITE | G_PARAM_STATIC_STRINGS));
+
+  g_object_class_install_properties (object_class, LAST_PROP, properties);
+
+  signals [ACTIVATE] =
+    g_signal_new ("activate",
+                  G_TYPE_FROM_CLASS (klass),
+                  G_SIGNAL_RUN_LAST,
+                  G_STRUCT_OFFSET (IdeEntryPopoverClass, activate),
+                  NULL, NULL, NULL,
+                  G_TYPE_NONE,
+                  1,
+                  G_TYPE_STRING);
+
+  signals [CHANGED] =
+    g_signal_new ("changed",
+                  G_TYPE_FROM_CLASS (klass),
+                  G_SIGNAL_RUN_LAST,
+                  G_STRUCT_OFFSET (IdeEntryPopoverClass, insert_text),
+                  NULL, NULL, NULL,
+                  G_TYPE_NONE,
+                  0);
+
+  signals [INSERT_TEXT] =
+    g_signal_new ("insert-text",
+                  G_TYPE_FROM_CLASS (klass),
+                  G_SIGNAL_RUN_LAST,
+                  G_STRUCT_OFFSET (IdeEntryPopoverClass, insert_text),
+                  NULL, NULL, NULL,
+                  G_TYPE_BOOLEAN,
+                  3,
+                  G_TYPE_UINT,
+                  G_TYPE_STRING,
+                  G_TYPE_UINT);
+
+  gtk_widget_class_set_template_from_resource (widget_class, "/org/gnome/libide-gtk/ide-entry-popover.ui");
+  gtk_widget_class_bind_template_child_private (widget_class, IdeEntryPopover, title);
+  gtk_widget_class_bind_template_child_private (widget_class, IdeEntryPopover, message);
+  gtk_widget_class_bind_template_child_private (widget_class, IdeEntryPopover, entry);
+  gtk_widget_class_bind_template_child_private (widget_class, IdeEntryPopover, button);
+}
+
+static void
+ide_entry_popover_init (IdeEntryPopover *self)
+{
+  IdeEntryPopoverPrivate *priv = ide_entry_popover_get_instance_private (self);
+
+  gtk_widget_init_template (GTK_WIDGET (self));
+
+  g_signal_connect_object (priv->button,
+                           "clicked",
+                           G_CALLBACK (ide_entry_popover_button_clicked),
+                           self,
+                           G_CONNECT_SWAPPED);
+
+  g_signal_connect_object (priv->entry,
+                           "changed",
+                           G_CALLBACK (ide_entry_popover_entry_changed),
+                           self,
+                           G_CONNECT_SWAPPED);
+
+  g_signal_connect_object (priv->entry,
+                           "activate",
+                           G_CALLBACK (ide_entry_popover_entry_activate),
+                           self,
+                           G_CONNECT_SWAPPED);
+
+  g_signal_connect_object (gtk_editable_get_delegate (GTK_EDITABLE (priv->entry)),
+                           "insert-text",
+                           G_CALLBACK (ide_entry_popover_entry_insert_text),
+                           self,
+                           G_CONNECT_SWAPPED);
+}
+
+GtkWidget *
+ide_entry_popover_new (void)
+{
+  return g_object_new (IDE_TYPE_ENTRY_POPOVER, NULL);
+}
+
+void
+ide_entry_popover_select_all (IdeEntryPopover *self)
+{
+  IdeEntryPopoverPrivate *priv = ide_entry_popover_get_instance_private (self);
+
+  g_return_if_fail (IDE_IS_ENTRY_POPOVER (self));
+
+  gtk_editable_select_region (GTK_EDITABLE (priv->entry), 0, -1);
+}
diff --git a/src/libide/gtk/ide-entry-popover.h b/src/libide/gtk/ide-entry-popover.h
new file mode 100644
index 000000000..d6469c6f5
--- /dev/null
+++ b/src/libide/gtk/ide-entry-popover.h
@@ -0,0 +1,103 @@
+/* ide-entry-popover.h
+ *
+ * Copyright (C) 2015-2022 Christian Hergert <christian hergert me>
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+#pragma once
+
+#include <gtk/gtk.h>
+
+#include <libide-core.h>
+
+G_BEGIN_DECLS
+
+#define IDE_TYPE_ENTRY_POPOVER (ide_entry_popover_get_type())
+
+IDE_AVAILABLE_IN_ALL
+G_DECLARE_DERIVABLE_TYPE (IdeEntryPopover, ide_entry_popover, IDE, ENTRY_POPOVER, GtkPopover)
+
+struct _IdeEntryPopoverClass
+{
+  GtkPopoverClass parent;
+
+  /**
+   * IdeEntryPopover::activate:
+   * @self: The #IdeEntryPopover instance.
+   * @text: The text at the time of activation.
+   *
+   * This signal is emitted when the popover's forward button is activated.
+   * Connect to this signal to perform your forward progress.
+   */
+  void (*activate) (IdeEntryPopover *self,
+                    const gchar      *text);
+
+  /**
+   * IdeEntryPopover::insert-text:
+   * @self: A #IdeEntryPopover.
+   * @position: the position in UTF-8 characters.
+   * @chars: the NULL terminated UTF-8 text to insert.
+   * @n_chars: the number of UTF-8 characters in chars.
+   *
+   * Use this signal to determine if text should be allowed to be inserted
+   * into the text buffer. Return GDK_EVENT_STOP to prevent the text from
+   * being inserted.
+   */
+  gboolean (*insert_text) (IdeEntryPopover *self,
+                           guint             position,
+                           const gchar      *chars,
+                           guint             n_chars);
+
+
+  /**
+   * IdeEntryPopover::changed:
+   * @self: A #IdeEntryPopover.
+   *
+   * This signal is emitted when the entry text changes.
+   */
+  void (*changed) (IdeEntryPopover *self);
+};
+
+IDE_AVAILABLE_IN_ALL
+GtkWidget   *ide_entry_popover_new             (void);
+IDE_AVAILABLE_IN_ALL
+const gchar *ide_entry_popover_get_text        (IdeEntryPopover *self);
+IDE_AVAILABLE_IN_ALL
+void         ide_entry_popover_set_text        (IdeEntryPopover *self,
+                                                const gchar     *text);
+IDE_AVAILABLE_IN_ALL
+const gchar *ide_entry_popover_get_message     (IdeEntryPopover *self);
+IDE_AVAILABLE_IN_ALL
+void         ide_entry_popover_set_message     (IdeEntryPopover *self,
+                                                const gchar     *message);
+IDE_AVAILABLE_IN_ALL
+const gchar *ide_entry_popover_get_title       (IdeEntryPopover *self);
+IDE_AVAILABLE_IN_ALL
+void         ide_entry_popover_set_title       (IdeEntryPopover *self,
+                                                const gchar     *title);
+IDE_AVAILABLE_IN_ALL
+const gchar *ide_entry_popover_get_button_text (IdeEntryPopover *self);
+IDE_AVAILABLE_IN_ALL
+void         ide_entry_popover_set_button_text (IdeEntryPopover *self,
+                                                const gchar     *button_text);
+IDE_AVAILABLE_IN_ALL
+gboolean     ide_entry_popover_get_ready       (IdeEntryPopover *self);
+IDE_AVAILABLE_IN_ALL
+void         ide_entry_popover_set_ready       (IdeEntryPopover *self,
+                                                gboolean         ready);
+IDE_AVAILABLE_IN_ALL
+void         ide_entry_popover_select_all      (IdeEntryPopover *self);
+
+G_END_DECLS
diff --git a/src/libide/gtk/ide-entry-popover.ui b/src/libide/gtk/ide-entry-popover.ui
new file mode 100644
index 000000000..3dd6be9a5
--- /dev/null
+++ b/src/libide/gtk/ide-entry-popover.ui
@@ -0,0 +1,58 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<interface>
+  <requires lib="gtk" version="4.0"/>
+  <template class="IdeEntryPopover" parent="GtkPopover">
+    <child>
+      <object class="GtkBox">
+        <property name="margin-top">6</property>
+        <property name="margin-bottom">6</property>
+        <property name="margin-start">6</property>
+        <property name="margin-end">6</property>
+        <property name="orientation">vertical</property>
+        <property name="spacing">12</property>
+        <property name="visible">true</property>
+        <child>
+          <object class="GtkLabel" id="title">
+            <property name="xalign">0.5</property>
+            <property name="visible">true</property>
+            <attributes>
+              <attribute name="weight" value="bold"/>
+            </attributes>
+          </object>
+        </child>
+        <child>
+          <object class="GtkBox">
+            <property name="orientation">horizontal</property>
+            <property name="spacing">9</property>
+            <property name="visible">true</property>
+            <child>
+              <object class="GtkEntry" id="entry">
+                <property name="width-chars">20</property>
+                <property name="visible">true</property>
+              </object>
+            </child>
+            <child>
+              <object class="GtkButton" id="button">
+                <property name="sensitive">false</property>
+                <property name="use-underline">true</property>
+                <property name="visible">true</property>
+                <style>
+                  <class name="suggested-action"/>
+                </style>
+              </object>
+            </child>
+          </object>
+        </child>
+        <child>
+          <object class="GtkLabel" id="message">
+            <property name="xalign">0.0</property>
+            <property name="visible">true</property>
+            <style>
+              <class name="dim-label"/>
+            </style>
+          </object>
+        </child>
+      </object>
+    </child>
+  </template>
+</interface>
diff --git a/src/libide/gtk/ide-enum-object.c b/src/libide/gtk/ide-enum-object.c
new file mode 100644
index 000000000..f40c9e7ea
--- /dev/null
+++ b/src/libide/gtk/ide-enum-object.c
@@ -0,0 +1,182 @@
+/* ide-enum-object.c
+ *
+ * Copyright 2022 Christian Hergert <chergert redhat com>
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+#define G_LOG_DOMAIN "ide-enum-object"
+
+#include "config.h"
+
+#include "ide-enum-object.h"
+
+struct _IdeEnumObject
+{
+  GObject parent_instance;
+  char *description;
+  char *nick;
+  char *title;
+};
+
+enum {
+  PROP_0,
+  PROP_DESCRIPTION,
+  PROP_NICK,
+  PROP_TITLE,
+  N_PROPS
+};
+
+G_DEFINE_FINAL_TYPE (IdeEnumObject, ide_enum_object, G_TYPE_OBJECT)
+
+static GParamSpec *properties [N_PROPS];
+
+static void
+ide_enum_object_dispose (GObject *object)
+{
+  IdeEnumObject *self = (IdeEnumObject *)object;
+
+  g_clear_pointer (&self->description, g_free);
+  g_clear_pointer (&self->nick, g_free);
+  g_clear_pointer (&self->title, g_free);
+
+  G_OBJECT_CLASS (ide_enum_object_parent_class)->dispose (object);
+}
+
+static void
+ide_enum_object_get_property (GObject    *object,
+                              guint       prop_id,
+                              GValue     *value,
+                              GParamSpec *pspec)
+{
+  IdeEnumObject *self = IDE_ENUM_OBJECT (object);
+
+  switch (prop_id)
+    {
+    case PROP_DESCRIPTION:
+      g_value_set_string (value, self->description);
+      break;
+
+    case PROP_NICK:
+      g_value_set_string (value, self->nick);
+      break;
+
+    case PROP_TITLE:
+      g_value_set_string (value, self->title);
+      break;
+
+    default:
+      G_OBJECT_WARN_INVALID_PROPERTY_ID (object, prop_id, pspec);
+    }
+}
+
+static void
+ide_enum_object_set_property (GObject      *object,
+                              guint         prop_id,
+                              const GValue *value,
+                              GParamSpec   *pspec)
+{
+  IdeEnumObject *self = IDE_ENUM_OBJECT (object);
+
+  switch (prop_id)
+    {
+    case PROP_DESCRIPTION:
+      self->description = g_value_dup_string (value);
+      break;
+
+    case PROP_NICK:
+      self->nick = g_value_dup_string (value);
+      break;
+
+    case PROP_TITLE:
+      self->title = g_value_dup_string (value);
+      break;
+
+    default:
+      G_OBJECT_WARN_INVALID_PROPERTY_ID (object, prop_id, pspec);
+    }
+}
+
+static void
+ide_enum_object_class_init (IdeEnumObjectClass *klass)
+{
+  GObjectClass *object_class = G_OBJECT_CLASS (klass);
+
+  object_class->dispose = ide_enum_object_dispose;
+  object_class->get_property = ide_enum_object_get_property;
+  object_class->set_property = ide_enum_object_set_property;
+
+  properties [PROP_DESCRIPTION] =
+    g_param_spec_string ("description", NULL, NULL, NULL,
+                         (G_PARAM_READWRITE |
+                          G_PARAM_CONSTRUCT_ONLY |
+                          G_PARAM_STATIC_STRINGS));
+
+  properties [PROP_NICK] =
+    g_param_spec_string ("nick", NULL, NULL, NULL,
+                         (G_PARAM_READWRITE |
+                          G_PARAM_CONSTRUCT_ONLY |
+                          G_PARAM_STATIC_STRINGS));
+
+  properties [PROP_TITLE] =
+    g_param_spec_string ("title", NULL, NULL, NULL,
+                         (G_PARAM_READWRITE |
+                          G_PARAM_CONSTRUCT_ONLY |
+                          G_PARAM_STATIC_STRINGS));
+
+  g_object_class_install_properties (object_class, N_PROPS, properties);
+}
+
+static void
+ide_enum_object_init (IdeEnumObject *self)
+{
+}
+
+const char *
+ide_enum_object_get_description (IdeEnumObject *self)
+{
+  g_return_val_if_fail (IDE_IS_ENUM_OBJECT (self), NULL);
+
+  return self->description;
+}
+
+const char *
+ide_enum_object_get_title (IdeEnumObject *self)
+{
+  g_return_val_if_fail (IDE_IS_ENUM_OBJECT (self), NULL);
+
+  return self->title;
+}
+
+const char *
+ide_enum_object_get_nick (IdeEnumObject *self)
+{
+  g_return_val_if_fail (IDE_IS_ENUM_OBJECT (self), NULL);
+
+  return self->nick;
+}
+
+IdeEnumObject *
+ide_enum_object_new (const char *nick,
+                     const char *title,
+                     const char *description)
+{
+  return g_object_new (IDE_TYPE_ENUM_OBJECT,
+                       "nick", nick,
+                       "title", title,
+                       "description", description,
+                       NULL);
+}
diff --git a/src/libide/gtk/ide-enum-object.h b/src/libide/gtk/ide-enum-object.h
new file mode 100644
index 000000000..1c98c6e59
--- /dev/null
+++ b/src/libide/gtk/ide-enum-object.h
@@ -0,0 +1,43 @@
+/* ide-enum-object.h
+ *
+ * Copyright 2022 Christian Hergert <chergert redhat com>
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+#pragma once
+
+#include <libide-core.h>
+
+G_BEGIN_DECLS
+
+#define IDE_TYPE_ENUM_OBJECT (ide_enum_object_get_type())
+
+IDE_AVAILABLE_IN_ALL
+G_DECLARE_FINAL_TYPE (IdeEnumObject, ide_enum_object, IDE, ENUM_OBJECT, GObject)
+
+IDE_AVAILABLE_IN_ALL
+IdeEnumObject *ide_enum_object_new             (const char    *nick,
+                                                const char    *title,
+                                                const char    *description);
+IDE_AVAILABLE_IN_ALL
+const char    *ide_enum_object_get_description (IdeEnumObject *self);
+IDE_AVAILABLE_IN_ALL
+const char    *ide_enum_object_get_nick        (IdeEnumObject *self);
+IDE_AVAILABLE_IN_ALL
+const char    *ide_enum_object_get_title       (IdeEnumObject *self);
+
+G_END_DECLS
diff --git a/src/libide/gui/ide-fancy-tree-view.c b/src/libide/gtk/ide-fancy-tree-view.c
similarity index 87%
rename from src/libide/gui/ide-fancy-tree-view.c
rename to src/libide/gtk/ide-fancy-tree-view.c
index 30cf656c7..4de01a94b 100644
--- a/src/libide/gui/ide-fancy-tree-view.c
+++ b/src/libide/gtk/ide-fancy-tree-view.c
@@ -22,8 +22,6 @@
 
 #include "config.h"
 
-#include <dazzle.h>
-
 #include "ide-cell-renderer-fancy.h"
 #include "ide-fancy-tree-view.h"
 
@@ -39,8 +37,6 @@
  *
  * It only has a single column, and comes setup with a single
  * cell (an #IdeCellRendererFancy) to render the conten.
- *
- * Since: 3.32
  */
 
 typedef struct
@@ -52,14 +48,14 @@ typedef struct
 G_DEFINE_TYPE_WITH_PRIVATE (IdeFancyTreeView, ide_fancy_tree_view, GTK_TYPE_TREE_VIEW)
 
 static void
-ide_fancy_tree_view_destroy (GtkWidget *widget)
+ide_fancy_tree_view_dispose (GObject *object)
 {
-  IdeFancyTreeView *self = (IdeFancyTreeView *)widget;
+  IdeFancyTreeView *self = (IdeFancyTreeView *)object;
   IdeFancyTreeViewPrivate *priv = ide_fancy_tree_view_get_instance_private (self);
 
-  dzl_clear_source (&priv->relayout_source);
+  g_clear_handle_id (&priv->relayout_source, g_source_remove);
 
-  GTK_WIDGET_CLASS (ide_fancy_tree_view_parent_class)->destroy (widget);
+  G_OBJECT_CLASS (ide_fancy_tree_view_parent_class)->dispose (object);
 }
 
 static gboolean
@@ -98,16 +94,18 @@ cleanup:
 
 static void
 ide_fancy_tree_view_size_allocate (GtkWidget *widget,
-                                   GtkAllocation *alloc)
+                                   int        width,
+                                   int        height,
+                                   int        baseline)
 {
   IdeFancyTreeView *self = (IdeFancyTreeView *)widget;
   IdeFancyTreeViewPrivate *priv = ide_fancy_tree_view_get_instance_private (self);
 
   g_assert (IDE_IS_FANCY_TREE_VIEW (self));
 
-  GTK_WIDGET_CLASS (ide_fancy_tree_view_parent_class)->size_allocate (widget, alloc);
+  GTK_WIDGET_CLASS (ide_fancy_tree_view_parent_class)->size_allocate (widget, width, height, baseline);
 
-  if (priv->last_width != alloc->width)
+  if (priv->last_width != width)
     {
       /*
        * We must perform our queued relayout from an idle callback
@@ -119,10 +117,10 @@ ide_fancy_tree_view_size_allocate (GtkWidget *widget,
        */
       if (priv->relayout_source == 0)
         priv->relayout_source =
-          gdk_threads_add_idle_full (G_PRIORITY_HIGH,
-                                     queue_relayout_in_idle,
-                                     g_object_ref (self),
-                                     g_object_unref);
+          g_idle_add_full (G_PRIORITY_HIGH,
+                           queue_relayout_in_idle,
+                           g_object_ref (self),
+                           g_object_unref);
     }
 }
 
@@ -130,9 +128,11 @@ static void
 ide_fancy_tree_view_class_init (IdeFancyTreeViewClass *klass)
 {
   GtkWidgetClass *widget_class = GTK_WIDGET_CLASS (klass);
+  GObjectClass *object_class = G_OBJECT_CLASS (klass);
+
+  object_class->dispose = ide_fancy_tree_view_dispose;
 
   widget_class->size_allocate = ide_fancy_tree_view_size_allocate;
-  widget_class->destroy = ide_fancy_tree_view_destroy;
 }
 
 static void
@@ -176,8 +176,6 @@ ide_fancy_tree_view_new (void)
  *
  * Sets the data func to use to update the text for the
  * #IdeCellRendererFancy cell renderer.
- *
- * Since: 3.32
  */
 void
 ide_fancy_tree_view_set_data_func (IdeFancyTreeView      *self,
diff --git a/src/libide/gui/ide-fancy-tree-view.h b/src/libide/gtk/ide-fancy-tree-view.h
similarity index 86%
rename from src/libide/gui/ide-fancy-tree-view.h
rename to src/libide/gtk/ide-fancy-tree-view.h
index e8c7caf0b..63de7d1a6 100644
--- a/src/libide/gui/ide-fancy-tree-view.h
+++ b/src/libide/gtk/ide-fancy-tree-view.h
@@ -20,31 +20,29 @@
 
 #pragma once
 
-#if !defined (IDE_GUI_INSIDE) && !defined (IDE_GUI_COMPILATION)
-# error "Only <libide-gui.h> can be included directly."
+#if !defined (IDE_GTK_INSIDE) && !defined (IDE_GTK_COMPILATION)
+# error "Only <libide-gtk.h> can be included directly."
 #endif
 
 #include <gtk/gtk.h>
+
 #include <libide-core.h>
 
 G_BEGIN_DECLS
 
 #define IDE_TYPE_FANCY_TREE_VIEW (ide_fancy_tree_view_get_type())
 
-IDE_AVAILABLE_IN_3_32
+IDE_AVAILABLE_IN_ALL
 G_DECLARE_DERIVABLE_TYPE (IdeFancyTreeView, ide_fancy_tree_view, IDE, FANCY_TREE_VIEW, GtkTreeView)
 
 struct _IdeFancyTreeViewClass
 {
   GtkTreeViewClass parent_class;
-
-  /*< private >*/
-  gpointer _reserved[8];
 };
 
-IDE_AVAILABLE_IN_3_32
+IDE_AVAILABLE_IN_ALL
 GtkWidget *ide_fancy_tree_view_new           (void);
-IDE_AVAILABLE_IN_3_32
+IDE_AVAILABLE_IN_ALL
 void       ide_fancy_tree_view_set_data_func (IdeFancyTreeView      *self,
                                               GtkCellLayoutDataFunc  func,
                                               gpointer               func_data,
diff --git a/src/libide/gtk/ide-file-chooser-entry.c b/src/libide/gtk/ide-file-chooser-entry.c
new file mode 100644
index 000000000..ca149a7e6
--- /dev/null
+++ b/src/libide/gtk/ide-file-chooser-entry.c
@@ -0,0 +1,558 @@
+/* ide-file-chooser-entry.c
+ *
+ * Copyright (C) 2016-2022 Christian Hergert <chergert redhat com>
+ *
+ * This file is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 2.1 of the License, or (at your option) any later version.
+ *
+ * This file is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+#define G_LOG_DOMAIN "ide-file-chooser-entry"
+
+#include "config.h"
+
+#include <glib/gi18n.h>
+
+#include "ide-file-chooser-entry.h"
+
+struct _IdeFileChooserEntry
+{
+  GtkWidget parent_class;
+
+  GtkEntry  *entry;
+  GtkButton *button;
+
+  GtkFileChooserDialog *dialog;
+  GtkFileFilter *filter;
+  GFile *file;
+  gchar *title;
+
+  GtkFileChooserAction action;
+
+  guint local_only : 1;
+  guint create_folders : 1;
+  guint do_overwrite_confirmation : 1;
+  guint select_multiple : 1;
+  guint show_hidden : 1;
+};
+
+enum {
+  PROP_0,
+  PROP_ACTION,
+  PROP_CREATE_FOLDERS,
+  PROP_DO_OVERWRITE_CONFIRMATION,
+  PROP_FILE,
+  PROP_FILTER,
+  PROP_LOCAL_ONLY,
+  PROP_SHOW_HIDDEN,
+  PROP_MAX_WIDTH_CHARS,
+  PROP_TITLE,
+  N_PROPS
+};
+
+G_DEFINE_FINAL_TYPE (IdeFileChooserEntry, ide_file_chooser_entry, GTK_TYPE_WIDGET)
+
+static GParamSpec *properties [N_PROPS];
+
+static void
+ide_file_chooser_entry_sync_to_dialog (IdeFileChooserEntry *self)
+{
+  GtkWidget *toplevel;
+  GtkWidget *default_widget;
+
+  g_assert (IDE_IS_FILE_CHOOSER_ENTRY (self));
+
+  if (self->dialog == NULL)
+    return;
+
+  g_object_set (self->dialog,
+                "action", self->action,
+                "create-folders", self->create_folders,
+                "do-overwrite-confirmation", self->do_overwrite_confirmation,
+                "local-only", self->local_only,
+                "show-hidden", self->show_hidden,
+                "filter", self->filter,
+                "title", self->title,
+                NULL);
+
+  if (self->file != NULL)
+    gtk_file_chooser_set_file (GTK_FILE_CHOOSER (self->dialog), self->file, NULL);
+
+  toplevel = GTK_WIDGET (gtk_widget_get_native (GTK_WIDGET (self)));
+
+  if (GTK_IS_WINDOW (toplevel))
+    gtk_window_set_transient_for (GTK_WINDOW (self->dialog), GTK_WINDOW (toplevel));
+
+  default_widget = gtk_dialog_get_widget_for_response (GTK_DIALOG (self->dialog),
+                                                       GTK_RESPONSE_OK);
+
+  switch (self->action)
+    {
+    case GTK_FILE_CHOOSER_ACTION_OPEN:
+      gtk_button_set_label (GTK_BUTTON (default_widget), _("Open"));
+      break;
+
+    case GTK_FILE_CHOOSER_ACTION_SELECT_FOLDER:
+      gtk_button_set_label (GTK_BUTTON (default_widget), _("Select"));
+      break;
+
+    case GTK_FILE_CHOOSER_ACTION_SAVE:
+      gtk_button_set_label (GTK_BUTTON (default_widget), _("Save"));
+      break;
+
+    default:
+      break;
+    }
+}
+
+static void
+ide_file_chooser_entry_dialog_response (IdeFileChooserEntry  *self,
+                                        gint                  response_id,
+                                        GtkFileChooserDialog *dialog)
+{
+  g_assert (IDE_IS_FILE_CHOOSER_ENTRY (self));
+  g_assert (GTK_IS_FILE_CHOOSER_DIALOG (dialog));
+
+  if (response_id == GTK_RESPONSE_OK)
+    {
+      g_autoptr(GFile) file = NULL;
+
+      file = gtk_file_chooser_get_file (GTK_FILE_CHOOSER (dialog));
+      if (file != NULL)
+        ide_file_chooser_entry_set_file (self, file);
+    }
+
+  self->dialog = NULL;
+
+  gtk_window_destroy (GTK_WINDOW (dialog));
+}
+
+static void
+ide_file_chooser_entry_ensure_dialog (IdeFileChooserEntry *self)
+{
+  g_assert (IDE_IS_FILE_CHOOSER_ENTRY (self));
+
+  if (self->dialog == NULL)
+    {
+      self->dialog = g_object_new (GTK_TYPE_FILE_CHOOSER_DIALOG,
+                                   "local-only", TRUE,
+                                   "modal", TRUE,
+                                   NULL);
+      g_signal_connect_object (self->dialog,
+                               "response",
+                               G_CALLBACK (ide_file_chooser_entry_dialog_response),
+                               self,
+                               G_CONNECT_SWAPPED);
+      gtk_dialog_add_buttons (GTK_DIALOG (self->dialog),
+                              _("Cancel"), GTK_RESPONSE_CANCEL,
+                              _("Open"), GTK_RESPONSE_OK,
+                              NULL);
+      gtk_dialog_set_default_response (GTK_DIALOG (self->dialog), GTK_RESPONSE_OK);
+    }
+
+  ide_file_chooser_entry_sync_to_dialog (self);
+}
+
+static void
+ide_file_chooser_entry_button_clicked (IdeFileChooserEntry *self,
+                                       GtkButton           *button)
+{
+  g_assert (IDE_IS_FILE_CHOOSER_ENTRY (self));
+  g_assert (GTK_IS_BUTTON (button));
+
+  ide_file_chooser_entry_ensure_dialog (self);
+  gtk_window_present (GTK_WINDOW (self->dialog));
+}
+
+static GFile *
+file_expand (const gchar *path)
+{
+  g_autofree gchar *relative = NULL;
+  g_autofree gchar *scheme = NULL;
+
+  if (path == NULL)
+    return g_file_new_for_path (g_get_home_dir ());
+
+  scheme = g_uri_parse_scheme (path);
+  if (scheme != NULL)
+    return g_file_new_for_uri (path);
+
+  if (g_path_is_absolute (path))
+    return g_file_new_for_path (path);
+
+  relative = g_build_filename (g_get_home_dir (),
+                               path[0] == '~' ? &path[1] : path,
+                               NULL);
+
+  return g_file_new_for_path (relative);
+}
+
+static void
+ide_file_chooser_entry_changed (IdeFileChooserEntry *self,
+                                GtkEntry            *entry)
+{
+  g_autoptr(GFile) file = NULL;
+
+  g_assert (IDE_IS_FILE_CHOOSER_ENTRY (self));
+  g_assert (GTK_IS_ENTRY (entry));
+
+  file = file_expand (gtk_editable_get_text (GTK_EDITABLE (entry)));
+
+  if (g_set_object (&self->file, file))
+    g_object_notify_by_pspec (G_OBJECT (self), properties[PROP_FILE]);
+}
+
+static void
+ide_file_chooser_entry_dispose (GObject *object)
+{
+  IdeFileChooserEntry *self = (IdeFileChooserEntry *)object;
+
+  g_clear_pointer ((GtkWindow **)&self->dialog, gtk_window_destroy);
+
+  G_OBJECT_CLASS (ide_file_chooser_entry_parent_class)->dispose (object);
+}
+
+static void
+ide_file_chooser_entry_finalize (GObject *object)
+{
+  IdeFileChooserEntry *self = (IdeFileChooserEntry *)object;
+
+  g_clear_object (&self->file);
+  g_clear_object (&self->filter);
+  g_clear_pointer (&self->title, g_free);
+
+  G_OBJECT_CLASS (ide_file_chooser_entry_parent_class)->finalize (object);
+}
+
+static void
+ide_file_chooser_entry_get_property (GObject    *object,
+                                     guint       prop_id,
+                                     GValue     *value,
+                                     GParamSpec *pspec)
+{
+  IdeFileChooserEntry *self = IDE_FILE_CHOOSER_ENTRY (object);
+
+  switch (prop_id)
+    {
+    case PROP_ACTION:
+      g_value_set_enum (value, self->action);
+      break;
+
+    case PROP_LOCAL_ONLY:
+      g_value_set_boolean (value, self->local_only);
+      break;
+
+    case PROP_CREATE_FOLDERS:
+      g_value_set_boolean (value, self->create_folders);
+      break;
+
+    case PROP_DO_OVERWRITE_CONFIRMATION:
+      g_value_set_boolean (value, self->do_overwrite_confirmation);
+      break;
+
+    case PROP_SHOW_HIDDEN:
+      g_value_set_boolean (value, self->show_hidden);
+      break;
+
+    case PROP_FILTER:
+      g_value_set_object (value, self->filter);
+      break;
+
+    case PROP_FILE:
+      g_value_take_object (value, ide_file_chooser_entry_get_file (self));
+      break;
+
+    case PROP_MAX_WIDTH_CHARS:
+      g_value_set_int (value, gtk_editable_get_max_width_chars (GTK_EDITABLE (self->entry)));
+      break;
+
+    case PROP_TITLE:
+      g_value_set_string (value, self->title);
+      break;
+
+    default:
+      G_OBJECT_WARN_INVALID_PROPERTY_ID (object, prop_id, pspec);
+    }
+}
+
+static void
+ide_file_chooser_entry_set_property (GObject      *object,
+                                     guint         prop_id,
+                                     const GValue *value,
+                                     GParamSpec   *pspec)
+{
+  IdeFileChooserEntry *self = IDE_FILE_CHOOSER_ENTRY (object);
+
+  switch (prop_id)
+    {
+    case PROP_ACTION:
+      self->action = g_value_get_enum (value);
+      break;
+
+    case PROP_LOCAL_ONLY:
+      self->local_only = g_value_get_boolean (value);
+      break;
+
+    case PROP_CREATE_FOLDERS:
+      self->create_folders= g_value_get_boolean (value);
+      break;
+
+    case PROP_DO_OVERWRITE_CONFIRMATION:
+      self->do_overwrite_confirmation = g_value_get_boolean (value);
+      break;
+
+    case PROP_SHOW_HIDDEN:
+      self->show_hidden = g_value_get_boolean (value);
+      break;
+
+    case PROP_FILTER:
+      g_clear_object (&self->filter);
+      self->filter = g_value_dup_object (value);
+      break;
+
+    case PROP_FILE:
+      ide_file_chooser_entry_set_file (self, g_value_get_object (value));
+      break;
+
+    case PROP_MAX_WIDTH_CHARS:
+      gtk_editable_set_max_width_chars (GTK_EDITABLE (self->entry), g_value_get_int (value));
+      break;
+
+    case PROP_TITLE:
+      g_free (self->title);
+      self->title = g_value_dup_string (value);
+      break;
+
+    default:
+      G_OBJECT_WARN_INVALID_PROPERTY_ID (object, prop_id, pspec);
+    }
+
+  ide_file_chooser_entry_sync_to_dialog (self);
+}
+
+static void
+ide_file_chooser_entry_class_init (IdeFileChooserEntryClass *klass)
+{
+  GObjectClass *object_class = G_OBJECT_CLASS (klass);
+  GtkWidgetClass *widget_class = GTK_WIDGET_CLASS (klass);
+
+  object_class->dispose = ide_file_chooser_entry_dispose;
+  object_class->finalize = ide_file_chooser_entry_finalize;
+  object_class->get_property = ide_file_chooser_entry_get_property;
+  object_class->set_property = ide_file_chooser_entry_set_property;
+
+  properties [PROP_ACTION] =
+    g_param_spec_enum ("action",
+                       NULL,
+                       NULL,
+                       GTK_TYPE_FILE_CHOOSER_ACTION,
+                       GTK_FILE_CHOOSER_ACTION_OPEN,
+                       (G_PARAM_READWRITE | G_PARAM_STATIC_STRINGS));
+
+  properties [PROP_CREATE_FOLDERS] =
+    g_param_spec_boolean ("create-folders",
+                          NULL,
+                          NULL,
+                          FALSE,
+                          (G_PARAM_READWRITE | G_PARAM_STATIC_STRINGS));
+
+  properties [PROP_DO_OVERWRITE_CONFIRMATION] =
+    g_param_spec_boolean ("do-overwrite-confirmation",
+                          NULL,
+                          NULL,
+                          FALSE,
+                          (G_PARAM_READWRITE | G_PARAM_STATIC_STRINGS));
+
+  properties [PROP_LOCAL_ONLY] =
+    g_param_spec_boolean ("local-only",
+                          NULL,
+                          NULL,
+                          FALSE,
+                          (G_PARAM_READWRITE | G_PARAM_STATIC_STRINGS));
+
+  properties [PROP_SHOW_HIDDEN] =
+    g_param_spec_boolean ("show-hidden",
+                          NULL,
+                          NULL,
+                          FALSE,
+                          (G_PARAM_READWRITE | G_PARAM_STATIC_STRINGS));
+
+  properties [PROP_FILTER] =
+    g_param_spec_object ("filter",
+                         NULL,
+                         NULL,
+                         GTK_TYPE_FILE_FILTER,
+                         (G_PARAM_READWRITE | G_PARAM_STATIC_STRINGS));
+
+  properties [PROP_FILE] =
+    g_param_spec_object ("file",
+                         NULL,
+                         NULL,
+                         G_TYPE_FILE,
+                         (G_PARAM_READWRITE | G_PARAM_STATIC_STRINGS));
+
+  properties [PROP_MAX_WIDTH_CHARS] =
+    g_param_spec_int ("max-width-chars",
+                      NULL,
+                      NULL,
+                      -1,
+                      G_MAXINT,
+                      -1,
+                      (G_PARAM_READWRITE | G_PARAM_STATIC_STRINGS));
+
+  properties [PROP_TITLE] =
+    g_param_spec_string ("title",
+                         NULL,
+                         NULL,
+                         NULL,
+                         (G_PARAM_READWRITE | G_PARAM_STATIC_STRINGS));
+
+  g_object_class_install_properties (object_class, N_PROPS, properties);
+
+  gtk_widget_class_set_layout_manager_type (widget_class, GTK_TYPE_BIN_LAYOUT);
+}
+
+static void
+ide_file_chooser_entry_init (IdeFileChooserEntry *self)
+{
+  GtkWidget *hbox;
+
+  hbox = g_object_new (GTK_TYPE_BOX,
+                       "orientation", GTK_ORIENTATION_HORIZONTAL,
+                       "visible", TRUE,
+                       NULL);
+  gtk_style_context_add_class (gtk_widget_get_style_context (hbox), "linked");
+  gtk_widget_set_parent (hbox, GTK_WIDGET (self));
+
+  self->entry = g_object_new (GTK_TYPE_ENTRY,
+                              "hexpand", TRUE,
+                              "visible", TRUE,
+                              NULL);
+  g_signal_connect_object (self->entry,
+                           "changed",
+                           G_CALLBACK (ide_file_chooser_entry_changed),
+                           self,
+                           G_CONNECT_SWAPPED);
+  gtk_box_append (GTK_BOX (hbox), GTK_WIDGET (self->entry));
+
+  self->button = g_object_new (GTK_TYPE_BUTTON,
+                               "label", _("Browse…"),
+                               "visible", TRUE,
+                               NULL);
+  g_signal_connect_object (self->button,
+                           "clicked",
+                           G_CALLBACK (ide_file_chooser_entry_button_clicked),
+                           self,
+                           G_CONNECT_SWAPPED);
+  gtk_box_append (GTK_BOX (hbox), GTK_WIDGET (self->button));
+}
+
+static gchar *
+file_collapse (GFile *file)
+{
+  gchar *path = NULL;
+
+  g_assert (!file || G_IS_FILE (file));
+
+  if (file == NULL)
+    return g_strdup ("");
+
+  if (!g_file_is_native (file))
+    return g_file_get_uri (file);
+
+  path = g_file_get_path (file);
+
+  if (path == NULL)
+    return g_strdup ("");
+
+  if (!g_path_is_absolute (path))
+    {
+      g_autofree gchar *freeme = path;
+
+      path = g_build_filename (g_get_home_dir (), freeme, NULL);
+    }
+
+  if (g_str_has_prefix (path, g_get_home_dir ()))
+    {
+      g_autofree gchar *freeme = path;
+
+      path = g_build_filename ("~",
+                               freeme + strlen (g_get_home_dir ()),
+                               NULL);
+    }
+
+  return path;
+}
+
+/**
+ * ide_file_chooser_entry_get_file:
+ *
+ * Returns the currently selected file or %NULL if there is no selection.
+ *
+ * Returns: (nullable) (transfer full): A #GFile or %NULL.
+ */
+GFile *
+ide_file_chooser_entry_get_file (IdeFileChooserEntry *self)
+{
+  g_return_val_if_fail (IDE_IS_FILE_CHOOSER_ENTRY (self), NULL);
+
+  return self->file ? g_object_ref (self->file) : NULL;
+}
+
+void
+ide_file_chooser_entry_set_file (IdeFileChooserEntry *self,
+                                 GFile               *file)
+{
+  g_autofree gchar *collapsed = NULL;
+
+  g_return_if_fail (IDE_IS_FILE_CHOOSER_ENTRY (self));
+
+  if (self->file == file || (self->file && file && g_file_equal (self->file, file)))
+    return;
+
+  if (file != NULL)
+    g_object_ref (file);
+
+  g_clear_object (&self->file);
+  self->file = file;
+
+  collapsed = file_collapse (file);
+  gtk_editable_set_text (GTK_EDITABLE (self->entry), collapsed);
+
+  g_object_notify_by_pspec (G_OBJECT (self), properties[PROP_FILE]);
+}
+
+GtkWidget *
+ide_file_chooser_entry_new (const gchar          *title,
+                            GtkFileChooserAction  action)
+{
+  return g_object_new (IDE_TYPE_FILE_CHOOSER_ENTRY,
+                       "title", title,
+                       "action", action,
+                       NULL);
+}
+
+/**
+ * ide_file_chooser_entry_get_entry:
+ * @self: a #IdeFileChooserEntry
+ *
+ * Gets the entry used by the #GtkEntry.
+ *
+ * Returns: (transfer none): a #GtkEntry
+ */
+GtkEntry *
+ide_file_chooser_entry_get_entry (IdeFileChooserEntry *self)
+{
+  g_return_val_if_fail (IDE_IS_FILE_CHOOSER_ENTRY (self), NULL);
+
+  return self->entry;
+}
diff --git a/src/libide/gtk/ide-file-chooser-entry.h b/src/libide/gtk/ide-file-chooser-entry.h
new file mode 100644
index 000000000..46ef2fed1
--- /dev/null
+++ b/src/libide/gtk/ide-file-chooser-entry.h
@@ -0,0 +1,47 @@
+/* ide-file-chooser-entry.h
+ *
+ * Copyright (C) 2016-2022 Christian Hergert <chergert redhat com>
+ *
+ * This file is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 2.1 of the License, or (at your option) any later version.
+ *
+ * This file is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+#pragma once
+
+#if !defined (IDE_GTK_INSIDE) && !defined (IDE_GTK_COMPILATION)
+# error "Only <libide-gtk.h> can be included directly."
+#endif
+
+#include <gtk/gtk.h>
+
+#include <libide-core.h>
+
+G_BEGIN_DECLS
+
+#define IDE_TYPE_FILE_CHOOSER_ENTRY (ide_file_chooser_entry_get_type())
+
+IDE_AVAILABLE_IN_ALL
+G_DECLARE_FINAL_TYPE (IdeFileChooserEntry, ide_file_chooser_entry, IDE, FILE_CHOOSER_ENTRY, GtkWidget)
+
+IDE_AVAILABLE_IN_ALL
+GtkWidget           *ide_file_chooser_entry_new       (const gchar          *title,
+                                                       GtkFileChooserAction  action);
+IDE_AVAILABLE_IN_ALL
+GFile               *ide_file_chooser_entry_get_file  (IdeFileChooserEntry  *self);
+IDE_AVAILABLE_IN_ALL
+void                 ide_file_chooser_entry_set_file  (IdeFileChooserEntry  *self,
+                                                       GFile                *file);
+IDE_AVAILABLE_IN_ALL
+GtkEntry            *ide_file_chooser_entry_get_entry (IdeFileChooserEntry  *self);
+
+G_END_DECLS
diff --git a/src/libide/gtk/ide-file-manager.c b/src/libide/gtk/ide-file-manager.c
new file mode 100644
index 000000000..ccb07dfda
--- /dev/null
+++ b/src/libide/gtk/ide-file-manager.c
@@ -0,0 +1,210 @@
+/* ide-file-manager.c
+ *
+ * Copyright (C) 1995-2017 GIMP Authors
+ * Copyright (C) 2015-2022 Christian Hergert <christian hergert me>
+ * Copyright (C) 2020      Germán Poo-Caamaño <gpoo gnome org>
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+#include "config.h"
+
+#include <glib/gi18n.h>
+
+#if defined(G_OS_WIN32)
+/* This is a hack for Windows known directory support.
+ * DATADIR (autotools-generated constant) is a type defined in objidl.h
+ * so we must #undef it before including shlobj.h in order to avoid a
+ * name clash. */
+#undef DATADIR
+#include <windows.h>
+#include <shlobj.h>
+#endif
+
+#ifdef PLATFORM_OSX
+#include <AppKit/AppKit.h>
+#endif
+
+#include "ide-file-manager.h"
+
+/* Copied from the GIMP */
+
+#if !(defined(G_OS_WIN32) || defined(PLATFORM_OSX))
+static void
+show_items_cb (GObject      *source_object,
+               GAsyncResult *result,
+               gpointer      user_data)
+{
+  GDBusProxy *proxy = (GDBusProxy *)source_object;
+  g_autoptr(GVariant) reply = NULL;
+  g_autoptr(GError) error = NULL;
+
+  g_assert (G_IS_DBUS_PROXY (proxy));
+  g_assert (G_IS_ASYNC_RESULT (result));
+  g_assert (user_data == NULL);
+
+  if (!(reply = g_dbus_proxy_call_finish (proxy, result, &error)))
+    g_warning ("Failed to show items: %s", error->message);
+}
+#endif /* !(defined(G_OS_WIN32) || defined(PLATFORM_OSX)) */
+
+/**
+ * ide_file_manager_show:
+ * @file: a #GFile to load within the desktop file manager
+ * @error: a location for a #GError, or %NULL
+ *
+ * Requests that @file is displayed within the default desktop file manager.
+ * Typically this means browsing to the parent directory and then selecting
+ * @file within that directory.
+ *
+ * Returns: %TRUE if successful; otherwise %FALSE and @error is set.
+ */
+gboolean
+ide_file_manager_show (GFile   *file,
+                       GError **error)
+{
+  g_return_val_if_fail (G_IS_FILE (file), FALSE);
+  g_return_val_if_fail (error == NULL || *error == NULL, FALSE);
+
+#if defined(G_OS_WIN32)
+
+  {
+    gboolean ret;
+    char *filename;
+    int n;
+    LPWSTR w_filename = NULL;
+    ITEMIDLIST *pidl = NULL;
+
+    ret = FALSE;
+
+    /* Calling this function multiple times should do no harm, but it is
+       easier to put this here as it needs linking against ole32. */
+    CoInitialize (NULL);
+
+    filename = g_file_get_path (file);
+    if (!filename)
+      {
+        g_set_error_literal (error, G_FILE_ERROR, 0,
+                             _("File path is NULL"));
+        goto out;
+      }
+
+    n = MultiByteToWideChar (CP_UTF8, MB_ERR_INVALID_CHARS,
+                             filename, -1, NULL, 0);
+    if (n == 0)
+      {
+        g_set_error_literal (error, G_FILE_ERROR, 0,
+                             _("Error converting UTF-8 filename to wide char"));
+        goto out;
+      }
+
+    w_filename = g_malloc_n (n + 1, sizeof (wchar_t));
+    n = MultiByteToWideChar (CP_UTF8, MB_ERR_INVALID_CHARS,
+                             filename, -1,
+                             w_filename, (n + 1) * sizeof (wchar_t));
+    if (n == 0)
+      {
+        g_set_error_literal (error, G_FILE_ERROR, 0,
+                             _("Error converting UTF-8 filename to wide char"));
+        goto out;
+      }
+
+    pidl = ILCreateFromPathW (w_filename);
+    if (!pidl)
+      {
+        g_set_error_literal (error, G_FILE_ERROR, 0,
+                             _("ILCreateFromPath() failed"));
+        goto out;
+      }
+
+    SHOpenFolderAndSelectItems (pidl, 0, NULL, 0);
+    ret = TRUE;
+
+  out:
+    if (pidl)
+      ILFree (pidl);
+    g_free (w_filename);
+    g_free (filename);
+
+    return ret;
+  }
+
+#elif defined(PLATFORM_OSX)
+
+  {
+    gchar    *uri;
+    NSString *filename;
+    NSURL    *url;
+    gboolean  retval = TRUE;
+
+    uri = g_file_get_uri (file);
+    filename = [NSString stringWithUTF8String:uri];
+
+    url = [NSURL URLWithString:filename];
+    if (url)
+      {
+        NSArray *url_array = [NSArray arrayWithObject:url];
+
+        [[NSWorkspace sharedWorkspace] activateFileViewerSelectingURLs:url_array];
+      }
+    else
+      {
+        g_set_error (error, G_FILE_ERROR, 0,
+                     _("Cannot convert “%s” into a valid NSURL."), uri);
+        retval = FALSE;
+      }
+
+    g_free (uri);
+
+    return retval;
+  }
+
+#else /* UNIX */
+
+  {
+    g_autofree gchar *uri = g_file_get_uri (file);
+    g_autoptr(GVariantBuilder) builder = NULL;
+    g_autoptr(GDBusProxy) proxy = NULL;
+
+    proxy = g_dbus_proxy_new_for_bus_sync (G_BUS_TYPE_SESSION,
+                                           (G_DBUS_PROXY_FLAGS_DO_NOT_LOAD_PROPERTIES |
+                                            G_DBUS_PROXY_FLAGS_DO_NOT_CONNECT_SIGNALS |
+                                            G_DBUS_PROXY_FLAGS_DO_NOT_AUTO_START_AT_CONSTRUCTION),
+                                           NULL,
+                                           "org.freedesktop.FileManager1",
+                                           "/org/freedesktop/FileManager1",
+                                           "org.freedesktop.FileManager1",
+                                           NULL,
+                                           error);
+
+    /* Implausible */
+    if (proxy == NULL)
+      return FALSE;
+
+    builder = g_variant_builder_new (G_VARIANT_TYPE ("as"));
+    g_variant_builder_add (builder, "s", uri);
+    g_dbus_proxy_call (proxy,
+                       "ShowItems",
+                       g_variant_new ("(ass)", builder, ""),
+                       G_DBUS_CALL_FLAGS_NONE,
+                       -1,
+                       NULL,
+                       show_items_cb,
+                       NULL);
+
+    return TRUE;
+  }
+
+#endif
+}
diff --git a/src/libide/gtk/ide-file-manager.h b/src/libide/gtk/ide-file-manager.h
new file mode 100644
index 000000000..851ff1edc
--- /dev/null
+++ b/src/libide/gtk/ide-file-manager.h
@@ -0,0 +1,30 @@
+/* ide-file-manager.h
+ *
+ * Copyright 2022 Christian Hergert <chergert redhat com>
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+#pragma once
+
+#include <gio/gio.h>
+
+G_BEGIN_DECLS
+
+gboolean ide_file_manager_show (GFile   *file,
+                                GError **error);
+
+G_END_DECLS
diff --git a/src/libide/gtk/ide-font-description.c b/src/libide/gtk/ide-font-description.c
new file mode 100644
index 000000000..40f5b5094
--- /dev/null
+++ b/src/libide/gtk/ide-font-description.c
@@ -0,0 +1,233 @@
+/* ide-font-description.c
+ *
+ * Copyright 2022 Christian Hergert <chergert redhat com>
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+#define G_LOG_DOMAIN "ide-font-description"
+
+#include "config.h"
+
+#include <math.h>
+
+#include "ide-font-description.h"
+
+#define FONT_FAMILY  "font-family"
+#define FONT_VARIANT "font-variant"
+#define FONT_STRETCH "font-stretch"
+#define FONT_WEIGHT  "font-weight"
+#define FONT_STYLE   "font-style"
+#define FONT_SIZE    "font-size"
+
+char *
+ide_font_description_to_css (const PangoFontDescription *font_desc)
+{
+       PangoFontMask mask;
+       GString *str;
+
+#define ADD_KEYVAL(key,fmt) \
+       g_string_append(str,key":"fmt";")
+#define ADD_KEYVAL_PRINTF(key,fmt,...) \
+       g_string_append_printf(str,key":"fmt";", __VA_ARGS__)
+
+       g_return_val_if_fail (font_desc, NULL);
+
+       str = g_string_new (NULL);
+
+       mask = pango_font_description_get_set_fields (font_desc);
+
+       if ((mask & PANGO_FONT_MASK_FAMILY) != 0)
+       {
+               const gchar *family;
+
+               family = pango_font_description_get_family (font_desc);
+               ADD_KEYVAL_PRINTF (FONT_FAMILY, "\"%s\"", family);
+       }
+
+       if ((mask & PANGO_FONT_MASK_STYLE) != 0)
+       {
+               PangoStyle style;
+
+               style = pango_font_description_get_style (font_desc);
+
+               switch (style)
+               {
+                       case PANGO_STYLE_NORMAL:
+                               ADD_KEYVAL (FONT_STYLE, "normal");
+                               break;
+
+                       case PANGO_STYLE_OBLIQUE:
+                               ADD_KEYVAL (FONT_STYLE, "oblique");
+                               break;
+
+                       case PANGO_STYLE_ITALIC:
+                               ADD_KEYVAL (FONT_STYLE, "italic");
+                               break;
+
+                       default:
+                               break;
+               }
+       }
+
+       if ((mask & PANGO_FONT_MASK_VARIANT) != 0)
+       {
+               PangoVariant variant;
+
+               variant = pango_font_description_get_variant (font_desc);
+
+               switch (variant)
+               {
+                       case PANGO_VARIANT_NORMAL:
+                               ADD_KEYVAL (FONT_VARIANT, "normal");
+                               break;
+
+                       case PANGO_VARIANT_SMALL_CAPS:
+                               ADD_KEYVAL (FONT_VARIANT, "small-caps");
+                               break;
+
+#if PANGO_VERSION_CHECK(1, 49, 3)
+                       case PANGO_VARIANT_ALL_SMALL_CAPS:
+                               ADD_KEYVAL (FONT_VARIANT, "all-small-caps");
+                               break;
+
+                       case PANGO_VARIANT_PETITE_CAPS:
+                               ADD_KEYVAL (FONT_VARIANT, "petite-caps");
+                               break;
+
+                       case PANGO_VARIANT_ALL_PETITE_CAPS:
+                               ADD_KEYVAL (FONT_VARIANT, "all-petite-caps");
+                               break;
+
+                       case PANGO_VARIANT_UNICASE:
+                               ADD_KEYVAL (FONT_VARIANT, "unicase");
+                               break;
+
+                       case PANGO_VARIANT_TITLE_CAPS:
+                               ADD_KEYVAL (FONT_VARIANT, "titling-caps");
+                               break;
+#endif
+
+                       default:
+                               break;
+               }
+       }
+
+       if ((mask & PANGO_FONT_MASK_WEIGHT))
+       {
+               gint weight;
+
+               weight = pango_font_description_get_weight (font_desc);
+
+               /*
+                * WORKAROUND:
+                *
+                * font-weight with numbers does not appear to be working as expected
+                * right now. So for the common (bold/normal), let's just use the string
+                * and let gtk warn for the other values, which shouldn't really be
+                * used for this.
+                */
+
+               switch (weight)
+               {
+                       case PANGO_WEIGHT_SEMILIGHT:
+                       /*
+                        * 350 is not actually a valid css font-weight, so we will just round
+                        * up to 400.
+                        */
+                       case PANGO_WEIGHT_NORMAL:
+                               ADD_KEYVAL (FONT_WEIGHT, "normal");
+                               break;
+
+                       case PANGO_WEIGHT_BOLD:
+                               ADD_KEYVAL (FONT_WEIGHT, "bold");
+                               break;
+
+                       case PANGO_WEIGHT_THIN:
+                       case PANGO_WEIGHT_ULTRALIGHT:
+                       case PANGO_WEIGHT_LIGHT:
+                       case PANGO_WEIGHT_BOOK:
+                       case PANGO_WEIGHT_MEDIUM:
+                       case PANGO_WEIGHT_SEMIBOLD:
+                       case PANGO_WEIGHT_ULTRABOLD:
+                       case PANGO_WEIGHT_HEAVY:
+                       case PANGO_WEIGHT_ULTRAHEAVY:
+                       default:
+                               /* round to nearest hundred */
+                               weight = round (weight / 100.0) * 100;
+                               ADD_KEYVAL_PRINTF ("font-weight", "%d", weight);
+                               break;
+               }
+       }
+
+       if ((mask & PANGO_FONT_MASK_STRETCH))
+       {
+               switch (pango_font_description_get_stretch (font_desc))
+               {
+                       case PANGO_STRETCH_ULTRA_CONDENSED:
+                               ADD_KEYVAL (FONT_STRETCH, "ultra-condensed");
+                               break;
+
+                       case PANGO_STRETCH_EXTRA_CONDENSED:
+                               ADD_KEYVAL (FONT_STRETCH, "extra-condensed");
+                               break;
+
+                       case PANGO_STRETCH_CONDENSED:
+                               ADD_KEYVAL (FONT_STRETCH, "condensed");
+                               break;
+
+                       case PANGO_STRETCH_SEMI_CONDENSED:
+                               ADD_KEYVAL (FONT_STRETCH, "semi-condensed");
+                               break;
+
+                       case PANGO_STRETCH_NORMAL:
+                               ADD_KEYVAL (FONT_STRETCH, "normal");
+                               break;
+
+                       case PANGO_STRETCH_SEMI_EXPANDED:
+                               ADD_KEYVAL (FONT_STRETCH, "semi-expanded");
+                               break;
+
+                       case PANGO_STRETCH_EXPANDED:
+                               ADD_KEYVAL (FONT_STRETCH, "expanded");
+                               break;
+
+                       case PANGO_STRETCH_EXTRA_EXPANDED:
+                               ADD_KEYVAL (FONT_STRETCH, "extra-expanded");
+                               break;
+
+                       case PANGO_STRETCH_ULTRA_EXPANDED:
+                               ADD_KEYVAL (FONT_STRETCH, "ultra-expanded");
+                               break;
+
+                       default:
+                               break;
+               }
+       }
+
+       if ((mask & PANGO_FONT_MASK_SIZE))
+       {
+               gint font_size;
+
+               font_size = pango_font_description_get_size (font_desc) / PANGO_SCALE;
+               ADD_KEYVAL_PRINTF ("font-size", "%dpt", font_size);
+       }
+
+       return g_string_free (str, FALSE);
+
+#undef ADD_KEYVAL
+#undef ADD_KEYVAL_PRINTF
+}
diff --git a/src/libide/gtk/ide-font-description.h b/src/libide/gtk/ide-font-description.h
new file mode 100644
index 000000000..d095ce7c9
--- /dev/null
+++ b/src/libide/gtk/ide-font-description.h
@@ -0,0 +1,33 @@
+/* ide-font-description.h
+ *
+ * Copyright 2022 Christian Hergert <chergert redhat com>
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+#pragma once
+
+#if !defined (IDE_GTK_INSIDE) && !defined (IDE_GTK_COMPILATION)
+# error "Only <libide-gtk.h> can be included directly."
+#endif
+
+#include <pango/pango.h>
+
+G_BEGIN_DECLS
+
+char *ide_font_description_to_css (const PangoFontDescription *font_desc);
+
+G_END_DECLS
diff --git a/src/libide/gtk/ide-frame-source-private.h b/src/libide/gtk/ide-frame-source-private.h
new file mode 100644
index 000000000..ea7b930b3
--- /dev/null
+++ b/src/libide/gtk/ide-frame-source-private.h
@@ -0,0 +1,34 @@
+/* ide-frame-source.h
+ *
+ * Copyright (C) 2010-2022 Christian Hergert <christian hergert me>
+ *
+ * This file is free software; you can redistribute it and/or modify it under
+ * the terms of the GNU Lesser General Public License as published by the Free
+ * Software Foundation; either version 2.1 of the License, or (at your option)
+ * any later version.
+ *
+ * This file is distributed in the hope that it will be useful, but WITHOUT
+ * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+ * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU Lesser General Public
+ * License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+#pragma once
+
+#include <libide-core.h>
+
+G_BEGIN_DECLS
+
+guint ide_frame_source_add      (guint       frames_per_sec,
+                                 GSourceFunc callback,
+                                 gpointer    user_data);
+guint ide_frame_source_add_full (gint           priority,
+                                 guint          frames_per_sec,
+                                 GSourceFunc    callback,
+                                 gpointer       user_data,
+                                 GDestroyNotify notify);
+
+G_END_DECLS
diff --git a/src/libide/gtk/ide-frame-source.c b/src/libide/gtk/ide-frame-source.c
new file mode 100644
index 000000000..e5ffe8a98
--- /dev/null
+++ b/src/libide/gtk/ide-frame-source.c
@@ -0,0 +1,173 @@
+/* ide-frame-source.c
+ *
+ * Copyright (C) 2010-2016 Christian Hergert <christian hergert me>
+ *
+ * This file is free software; you can redistribute it and/or modify it under
+ * the terms of the GNU Lesser General Public License as published by the Free
+ * Software Foundation; either version 2.1 of the License, or (at your option)
+ * any later version.
+ *
+ * This file is distributed in the hope that it will be useful, but WITHOUT
+ * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+ * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU Lesser General Public
+ * License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+#define G_LOG_DOMAIN "ide-frame-source"
+
+#include "config.h"
+
+#include "ide-frame-source-private.h"
+
+/**
+ * SECTION:ide-frame-source
+ * @title: IdeFrameSource
+ * @short_description: a frame source for objects without frame clocks
+ *
+ * If you are working with something that is not a #GtkWidget, getting
+ * access to a frame-clock is sometimes not possible. This can be used
+ * as a suitable fallback that approximates a frame-rate.
+ *
+ * If you have access to a #GdkFrameClock, in most cases you'll want that
+ * instead of using this.
+ */
+
+typedef struct
+{
+   GSource parent;
+   guint   fps;
+   guint   frame_count;
+   gint64  start_time;
+} IdeFrameSource;
+
+static gboolean
+ide_frame_source_prepare (GSource *source,
+                          gint    *timeout_)
+{
+   IdeFrameSource *fsource = (IdeFrameSource *)(gpointer)source;
+   gint64 current_time;
+   guint elapsed_time;
+   guint new_frame_num;
+   guint frame_time;
+
+   current_time = g_source_get_time(source) / 1000;
+   elapsed_time = current_time - fsource->start_time;
+   new_frame_num = elapsed_time * fsource->fps / 1000;
+
+   /* If time has gone backwards or the time since the last frame is
+    * greater than the two frames worth then reset the time and do a
+    * frame now */
+   if (new_frame_num < fsource->frame_count ||
+       new_frame_num - fsource->frame_count > 2) {
+      /* Get the frame time rounded up to the nearest ms */
+      frame_time = (1000 + fsource->fps - 1) / fsource->fps;
+
+      /* Reset the start time */
+      fsource->start_time = current_time;
+
+      /* Move the start time as if one whole frame has elapsed */
+      fsource->start_time -= frame_time;
+      fsource->frame_count = 0;
+      *timeout_ = 0;
+      return TRUE;
+   } else if (new_frame_num > fsource->frame_count) {
+      *timeout_ = 0;
+      return TRUE;
+   } else {
+      *timeout_ = (fsource->frame_count + 1) * 1000 / fsource->fps - elapsed_time;
+      return FALSE;
+   }
+}
+
+static gboolean
+ide_frame_source_check (GSource *source)
+{
+   gint timeout_;
+   return ide_frame_source_prepare(source, &timeout_);
+}
+
+static gboolean
+ide_frame_source_dispatch (GSource     *source,
+                           GSourceFunc  source_func,
+                           gpointer     user_data)
+{
+   IdeFrameSource *fsource = (IdeFrameSource *)(gpointer)source;
+   gboolean ret;
+
+   if ((ret = source_func(user_data)))
+      fsource->frame_count++;
+   return ret;
+}
+
+static GSourceFuncs source_funcs = {
+   ide_frame_source_prepare,
+   ide_frame_source_check,
+   ide_frame_source_dispatch,
+};
+
+/**
+ * ide_frame_source_add:
+ * @frames_per_sec: (in): Target frames per second.
+ * @callback: (in) (scope notified): A #GSourceFunc to execute.
+ * @user_data: (in): User data for @callback.
+ *
+ * Creates a new frame source that will execute when the timeout interval
+ * for the source has elapsed. The timing will try to synchronize based
+ * on the end time of the animation.
+ *
+ * Returns: A source id that can be removed with g_source_remove().
+ */
+guint
+ide_frame_source_add (guint       frames_per_sec,
+                      GSourceFunc callback,
+                      gpointer    user_data)
+{
+   IdeFrameSource *fsource;
+   GSource *source;
+   guint ret;
+
+   g_return_val_if_fail (frames_per_sec > 0, 0);
+
+   source = g_source_new (&source_funcs, sizeof (IdeFrameSource));
+   fsource = (IdeFrameSource *)(gpointer)source;
+   fsource->fps = frames_per_sec;
+   fsource->frame_count = 0;
+   fsource->start_time = g_get_monotonic_time () / 1000L;
+   g_source_set_callback (source, callback, user_data, NULL);
+   g_source_set_name (source, "IdeFrameSource");
+
+   ret = g_source_attach (source, NULL);
+   g_source_unref (source);
+
+   return ret;
+}
+
+guint
+ide_frame_source_add_full (gint           priority,
+                           guint          frames_per_sec,
+                           GSourceFunc    callback,
+                           gpointer       user_data,
+                           GDestroyNotify notify)
+{
+   IdeFrameSource *fsource;
+   GSource *source;
+   guint ret;
+
+   g_return_val_if_fail (frames_per_sec > 0, 0);
+
+   source = g_source_new (&source_funcs, sizeof (IdeFrameSource));
+   fsource = (IdeFrameSource *)(gpointer)source;
+   fsource->fps = frames_per_sec;
+   fsource->frame_count = 0;
+   fsource->start_time = g_get_monotonic_time () / 1000L;
+   g_source_set_callback (source, callback, user_data, notify);
+   g_source_set_name (source, "IdeFrameSource");
+
+   ret = g_source_attach (source, NULL);
+   g_source_unref (source);
+
+   return ret;
+}
diff --git a/src/libide/gtk/ide-gtk-init.c b/src/libide/gtk/ide-gtk-init.c
new file mode 100644
index 000000000..21dbb1cde
--- /dev/null
+++ b/src/libide/gtk/ide-gtk-init.c
@@ -0,0 +1,58 @@
+/* ide-gtk-init.c
+ *
+ * Copyright 2022 Christian Hergert <chergert redhat com>
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+#define G_LOG_DOMAIN "ide-gtk-init"
+
+#include "config.h"
+
+#include "ide-gtk-resources.h"
+
+#include "ide-animation.h"
+#include "ide-entry-popover.h"
+#include "ide-file-chooser-entry.h"
+#include "ide-gtk-private.h"
+#include "ide-cell-renderer-fancy.h"
+#include "ide-enum-object.h"
+#include "ide-fancy-tree-view.h"
+#include "ide-progress-icon.h"
+#include "ide-radio-box.h"
+#include "ide-search-entry.h"
+#include "ide-shortcut-accel-dialog.h"
+#include "ide-tree-expander.h"
+#include "ide-truncate-model.h"
+
+void
+_ide_gtk_init (void)
+{
+  g_type_ensure (IDE_TYPE_ANIMATION);
+  g_type_ensure (IDE_TYPE_CELL_RENDERER_FANCY);
+  g_type_ensure (IDE_TYPE_ENUM_OBJECT);
+  g_type_ensure (IDE_TYPE_ENTRY_POPOVER);
+  g_type_ensure (IDE_TYPE_FANCY_TREE_VIEW);
+  g_type_ensure (IDE_TYPE_FILE_CHOOSER_ENTRY);
+  g_type_ensure (IDE_TYPE_PROGRESS_ICON);
+  g_type_ensure (IDE_TYPE_RADIO_BOX);
+  g_type_ensure (IDE_TYPE_SEARCH_ENTRY);
+  g_type_ensure (IDE_TYPE_SHORTCUT_ACCEL_DIALOG);
+  g_type_ensure (IDE_TYPE_TREE_EXPANDER);
+  g_type_ensure (IDE_TYPE_TRUNCATE_MODEL);
+
+  g_resources_register (ide_gtk_get_resource ());
+}
diff --git a/src/libide/gtk/ide-gtk-private.h b/src/libide/gtk/ide-gtk-private.h
new file mode 100644
index 000000000..17f2a4c78
--- /dev/null
+++ b/src/libide/gtk/ide-gtk-private.h
@@ -0,0 +1,29 @@
+/* ide-gtk-private.h
+ *
+ * Copyright 2022 Christian Hergert <chergert redhat com>
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+#pragma once
+
+#include <glib.h>
+
+G_BEGIN_DECLS
+
+void _ide_gtk_init (void);
+
+G_END_DECLS
diff --git a/src/libide/gtk/ide-gtk.c b/src/libide/gtk/ide-gtk.c
new file mode 100644
index 000000000..56e2f295f
--- /dev/null
+++ b/src/libide/gtk/ide-gtk.c
@@ -0,0 +1,563 @@
+/* ide-gtk.c
+ *
+ * Copyright 2015-2022 Christian Hergert <chergert redhat com>
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+#define G_LOG_DOMAIN "ide-gtk"
+
+#include "config.h"
+
+#include <glib/gi18n.h>
+
+#include <libide-threading.h>
+
+#include "ide-animation.h"
+#include "ide-gtk.h"
+
+void
+ide_gtk_window_present (GtkWindow *window)
+{
+  /* TODO: We need the last event time to do this properly. Until then,
+   * we'll just fake some timing info to workaround wayland issues.
+   */
+  gtk_window_present_with_time (window, g_get_monotonic_time () / 1000L);
+}
+
+static void
+ide_gtk_show_uri_on_window_cb (GObject      *object,
+                               GAsyncResult *result,
+                               gpointer      user_data)
+{
+  IdeSubprocess *subprocess = (IdeSubprocess *)object;
+  g_autoptr(GError) error = NULL;
+
+  g_assert (IDE_IS_SUBPROCESS (subprocess));
+  g_assert (G_IS_ASYNC_RESULT (result));
+
+  if (!ide_subprocess_wait_finish (subprocess, result, &error))
+    g_warning ("Subprocess failed: %s", error->message);
+}
+
+gboolean
+ide_gtk_show_uri_on_window (GtkWindow    *window,
+                            const gchar  *uri,
+                            gint64        timestamp,
+                            GError      **error)
+{
+  g_return_val_if_fail (!window || GTK_IS_WINDOW (window), FALSE);
+  g_return_val_if_fail (uri != NULL, FALSE);
+
+  if (ide_is_flatpak ())
+    {
+      g_autoptr(IdeSubprocessLauncher) launcher = NULL;
+      g_autoptr(IdeSubprocess) subprocess = NULL;
+
+      /* We can't currently trust gtk_show_uri_on_window() because it tries
+       * to open our HTML page with Builder inside our current flatpak
+       * environment! We need to ensure this is fixed upstream, but it's
+       * currently unclear how to do so since we register handles for html.
+       */
+
+      launcher = ide_subprocess_launcher_new (0);
+      ide_subprocess_launcher_set_run_on_host (launcher, TRUE);
+      ide_subprocess_launcher_set_clear_env (launcher, FALSE);
+      ide_subprocess_launcher_push_argv (launcher, "xdg-open");
+      ide_subprocess_launcher_push_argv (launcher, uri);
+
+      if (!(subprocess = ide_subprocess_launcher_spawn (launcher, NULL, error)))
+        return FALSE;
+
+      ide_subprocess_wait_async (subprocess,
+                                 NULL,
+                                 ide_gtk_show_uri_on_window_cb,
+                                 NULL);
+    }
+  else
+    {
+      /* XXX: Workaround for wayland timestamp issue */
+      gtk_show_uri (window, uri, timestamp / 1000L);
+    }
+
+  return TRUE;
+}
+
+static gboolean
+ide_gtk_progress_bar_tick_cb (gpointer data)
+{
+  GtkProgressBar *progress = data;
+
+  g_assert (GTK_IS_PROGRESS_BAR (progress));
+
+  gtk_progress_bar_pulse (progress);
+  gtk_widget_queue_draw (GTK_WIDGET (progress));
+
+  return G_SOURCE_CONTINUE;
+}
+
+void
+ide_gtk_progress_bar_stop_pulsing (GtkProgressBar *progress)
+{
+  guint tick_id;
+
+  g_return_if_fail (GTK_IS_PROGRESS_BAR (progress));
+
+  tick_id = GPOINTER_TO_UINT (g_object_get_data (G_OBJECT (progress), "PULSE_ID"));
+
+  if (tick_id != 0)
+    {
+      g_source_remove (tick_id);
+      g_object_set_data (G_OBJECT (progress), "PULSE_ID", NULL);
+    }
+
+  gtk_progress_bar_set_fraction (progress, 0.0);
+}
+
+void
+ide_gtk_progress_bar_start_pulsing (GtkProgressBar *progress)
+{
+  guint tick_id;
+
+  g_return_if_fail (GTK_IS_PROGRESS_BAR (progress));
+
+  if (g_object_get_data (G_OBJECT (progress), "PULSE_ID"))
+    return;
+
+  gtk_progress_bar_set_fraction (progress, 0.0);
+  gtk_progress_bar_set_pulse_step (progress, .5);
+
+  /* We want lower than the frame rate, because that is all that is needed */
+  tick_id = g_timeout_add_full (G_PRIORITY_LOW,
+                                500,
+                                ide_gtk_progress_bar_tick_cb,
+                                g_object_ref (progress),
+                                g_object_unref);
+  g_object_set_data (G_OBJECT (progress), "PULSE_ID", GUINT_TO_POINTER (tick_id));
+  ide_gtk_progress_bar_tick_cb (progress);
+}
+
+static void
+show_callback (gpointer data)
+{
+  g_object_set_data (data, "IDE_FADE_ANIMATION", NULL);
+  g_object_unref (data);
+}
+
+static void
+hide_callback (gpointer data)
+{
+  GtkWidget *widget = data;
+
+  g_object_set_data (data, "IDE_FADE_ANIMATION", NULL);
+  gtk_widget_hide (widget);
+  gtk_widget_set_opacity (widget, 1.0);
+  g_object_unref (widget);
+}
+
+void
+ide_gtk_widget_show_with_fade (GtkWidget *widget)
+{
+  GdkFrameClock *frame_clock;
+  IdeAnimation *anim;
+
+  g_return_if_fail (GTK_IS_WIDGET (widget));
+
+  if (!gtk_widget_get_visible (widget))
+    {
+      anim = g_object_get_data (G_OBJECT (widget), "IDE_FADE_ANIMATION");
+      if (anim != NULL)
+        ide_animation_stop (anim);
+
+      frame_clock = gtk_widget_get_frame_clock (widget);
+      gtk_widget_set_opacity (widget, 0.0);
+      gtk_widget_show (widget);
+      anim = ide_object_animate_full (widget,
+                                      IDE_ANIMATION_LINEAR,
+                                      500,
+                                      frame_clock,
+                                      show_callback,
+                                      g_object_ref (widget),
+                                      "opacity", 1.0,
+                                      NULL);
+      g_object_set_data_full (G_OBJECT (widget),
+                              "IDE_FADE_ANIMATION",
+                              g_object_ref (anim),
+                              g_object_unref);
+    }
+}
+
+void
+ide_gtk_widget_hide_with_fade (GtkWidget *widget)
+{
+  GdkFrameClock *frame_clock;
+  IdeAnimation *anim;
+
+  g_return_if_fail (GTK_IS_WIDGET (widget));
+
+  if (gtk_widget_get_visible (widget))
+    {
+      anim = g_object_get_data (G_OBJECT (widget), "IDE_FADE_ANIMATION");
+      if (anim != NULL)
+        ide_animation_stop (anim);
+
+      frame_clock = gtk_widget_get_frame_clock (widget);
+      anim = ide_object_animate_full (widget,
+                                      IDE_ANIMATION_LINEAR,
+                                      1000,
+                                      frame_clock,
+                                      hide_callback,
+                                      g_object_ref (widget),
+                                      "opacity", 0.0,
+                                      NULL);
+      g_object_set_data_full (G_OBJECT (widget),
+                              "IDE_FADE_ANIMATION",
+                              g_object_ref (anim),
+                              g_object_unref);
+    }
+}
+
+static gboolean
+list_store_iter_middle (GtkListStore      *store,
+                        const GtkTreeIter *begin,
+                        const GtkTreeIter *end,
+                        GtkTreeIter       *middle)
+{
+  g_assert (store != NULL);
+  g_assert (begin != NULL);
+  g_assert (end != NULL);
+  g_assert (middle != NULL);
+  g_assert (middle->stamp == begin->stamp);
+  g_assert (middle->stamp == end->stamp);
+
+  /*
+   * middle MUST ALREADY BE VALID as it saves us some copying
+   * as well as just makes things easier when binary searching.
+   */
+
+  middle->user_data = g_sequence_range_get_midpoint (begin->user_data, end->user_data);
+
+  if (g_sequence_iter_is_end (middle->user_data))
+    {
+      middle->stamp = 0;
+      return FALSE;
+    }
+
+  return TRUE;
+}
+
+static inline gboolean
+list_store_iter_equal (const GtkTreeIter *a,
+                       const GtkTreeIter *b)
+{
+  return a->user_data == b->user_data;
+}
+
+/**
+ * ide_gtk_list_store_insert_sorted: (skip)
+ * @store: A #GtkListStore
+ * @iter: (out): A location for a #GtkTextIter
+ * @key: A key to compare to when binary searching
+ * @compare_column: the column containing the data to compare
+ * @compare_func: (scope call) (closure compare_data): A callback to compare
+ * @compare_data: data for @compare_func
+ *
+ * This function will binary search the contents of @store looking for the
+ * location to insert a new row.
+ *
+ * @compare_column must be the index of a column that is a %G_TYPE_POINTER,
+ * %G_TYPE_BOXED or %G_TYPE_OBJECT based column.
+ *
+ * @compare_func will be called with @key as the first parameter and the
+ * value from the #GtkListStore row as the second parameter. The third and
+ * final parameter is @compare_data.
+ */
+void
+ide_gtk_list_store_insert_sorted (GtkListStore     *store,
+                                  GtkTreeIter      *iter,
+                                  gconstpointer     key,
+                                  guint             compare_column,
+                                  GCompareDataFunc  compare_func,
+                                  gpointer          compare_data)
+{
+  GValue value = G_VALUE_INIT;
+  gpointer (*get_func) (const GValue *) = NULL;
+  GtkTreeModel *model = (GtkTreeModel *)store;
+  GtkTreeIter begin;
+  GtkTreeIter end;
+  GtkTreeIter middle;
+  guint n_children;
+  gint cmpval = 0;
+  GType type;
+
+  g_return_if_fail (GTK_IS_LIST_STORE (store));
+  g_return_if_fail (GTK_IS_LIST_STORE (model));
+  g_return_if_fail (iter != NULL);
+  g_return_if_fail (compare_column < gtk_tree_model_get_n_columns (GTK_TREE_MODEL (store)));
+  g_return_if_fail (compare_func != NULL);
+
+  type = gtk_tree_model_get_column_type (GTK_TREE_MODEL (store), compare_column);
+
+  if (g_type_is_a (type, G_TYPE_POINTER))
+    get_func = g_value_get_pointer;
+  else if (g_type_is_a (type, G_TYPE_BOXED))
+    get_func = g_value_get_boxed;
+  else if (g_type_is_a (type, G_TYPE_OBJECT))
+    get_func = g_value_get_object;
+  else
+    {
+      g_warning ("%s() only supports pointer, boxed, or object columns",
+                 G_STRFUNC);
+      gtk_list_store_append (store, iter);
+      return;
+    }
+
+  /* Try to get the first iter instead of calling n_children to
+   * avoid walking the GSequence all the way to the right. If this
+   * matches, we know there are some children.
+   */
+  if (!gtk_tree_model_get_iter_first (model, &begin))
+    {
+      gtk_list_store_append (store, iter);
+      return;
+    }
+
+  n_children = gtk_tree_model_iter_n_children (model, NULL);
+  if (!gtk_tree_model_iter_nth_child (model, &end, NULL, n_children - 1))
+    g_assert_not_reached ();
+
+  middle = begin;
+
+  while (list_store_iter_middle (store, &begin, &end, &middle))
+    {
+      gtk_tree_model_get_value (model, &middle, compare_column, &value);
+      cmpval = compare_func (key, get_func (&value), compare_data);
+      g_value_unset (&value);
+
+      if (cmpval == 0 || list_store_iter_equal (&begin, &end))
+        break;
+
+      if (cmpval < 0)
+        {
+          end = middle;
+
+          if (!list_store_iter_equal (&begin, &end) &&
+              !gtk_tree_model_iter_previous (model, &end))
+            break;
+        }
+      else if (cmpval > 0)
+        {
+          begin = middle;
+
+          if (!list_store_iter_equal (&begin, &end) &&
+              !gtk_tree_model_iter_next (model, &begin))
+            break;
+        }
+      else
+        g_assert_not_reached ();
+    }
+
+  if (cmpval < 0)
+    gtk_list_store_insert_before (store, iter, &middle);
+  else
+    gtk_list_store_insert_after (store, iter, &middle);
+}
+
+void
+ide_gtk_widget_destroyed (GtkWidget  *widget,
+                          GtkWidget **location)
+{
+  if (location != NULL)
+    *location = NULL;
+}
+
+/**
+ * ide_g_time_span_to_label:
+ * @span: the span of time
+ *
+ * Creates a string describing the time span in hours, minutes, and seconds.
+ * For example, a time span of three and a half minutes would be "3:30".
+ * 2 days, 3 hours, 6 minutes, and 20 seconds would be "51:06:20".
+ *
+ * Returns: (transfer full): A newly allocated string describing the time span.
+ */
+char *
+ide_g_time_span_to_label (GTimeSpan span)
+{
+  gint64 hours;
+  gint64 minutes;
+  gint64 seconds;
+
+  span = ABS (span);
+
+  hours = span / G_TIME_SPAN_HOUR;
+  minutes = (span % G_TIME_SPAN_HOUR) / G_TIME_SPAN_MINUTE;
+  seconds = (span % G_TIME_SPAN_MINUTE) / G_TIME_SPAN_SECOND;
+
+  g_assert (minutes < 60);
+  g_assert (seconds < 60);
+
+  if (hours == 0)
+    return g_strdup_printf ("%02"G_GINT64_FORMAT":%02"G_GINT64_FORMAT,
+                            minutes, seconds);
+  else
+    return g_strdup_printf ("%02"G_GINT64_FORMAT":%02"G_GINT64_FORMAT":%02"G_GINT64_FORMAT,
+                            hours, minutes, seconds);
+}
+
+/**
+ * ide_g_date_time_format_for_display:
+ * @self: A #GDateTime
+ *
+ * Helper function to create a human-friendly string describing approximately
+ * how long ago a #GDateTime is.
+ *
+ * Returns: (transfer full): A newly allocated string describing the
+ *   date and time imprecisely such as "Yesterday".
+ */
+char *
+ide_g_date_time_format_for_display (GDateTime *self)
+{
+  g_autoptr(GDateTime) now = NULL;
+  GTimeSpan diff;
+  gint years;
+
+  /*
+   * TODO:
+   *
+   * There is probably a lot more we can do here to be friendly for
+   * various locales, but this will get us started.
+   */
+
+  g_return_val_if_fail (self != NULL, NULL);
+
+  now = g_date_time_new_now_utc ();
+  diff = g_date_time_difference (now, self) / G_USEC_PER_SEC;
+
+  if (diff < 0)
+    return g_strdup ("");
+  else if (diff < (60 * 45))
+    return g_strdup (_("Just now"));
+  else if (diff < (60 * 90))
+    return g_strdup (_("An hour ago"));
+  else if (diff < (60 * 60 * 24 * 2))
+    return g_strdup (_("Yesterday"));
+  else if (diff < (60 * 60 * 24 * 7))
+    return g_date_time_format (self, "%A");
+  else if (diff < (60 * 60 * 24 * 365))
+    return g_date_time_format (self, "%OB");
+  else if (diff < (60 * 60 * 24 * 365 * 1.5))
+    return g_strdup (_("About a year ago"));
+
+  years = MAX (2, diff / (60 * 60 * 24 * 365));
+
+  return g_strdup_printf (ngettext ("About %u year ago", "About %u years ago", years), years);
+}
+
+void
+ide_gtk_list_view_move_next (GtkListView *view)
+{
+  GtkSelectionModel *model;
+  GtkBitset *bitset;
+  guint pos = 0;
+
+  g_return_if_fail (GTK_IS_LIST_VIEW (view));
+
+  if (!(model = gtk_list_view_get_model (view)))
+    return;
+
+  bitset = gtk_selection_model_get_selection (model);
+  if (!gtk_bitset_is_empty (bitset))
+    pos = gtk_bitset_get_minimum (bitset) + 1;
+
+  if (pos < g_list_model_get_n_items (G_LIST_MODEL (model)))
+    {
+      gtk_selection_model_select_item (model, pos, TRUE);
+      gtk_widget_activate_action (GTK_WIDGET (view), "list.scroll-to-item", "u", pos);
+    }
+}
+
+void
+ide_gtk_list_view_move_previous (GtkListView *view)
+{
+  GtkSelectionModel *model;
+  GtkBitset *bitset;
+  guint pos = 0;
+
+  g_return_if_fail (GTK_IS_LIST_VIEW (view));
+
+  if (!(model = gtk_list_view_get_model (view)))
+    return;
+
+  bitset = gtk_selection_model_get_selection (model);
+  if (!gtk_bitset_is_empty (bitset))
+    pos = gtk_bitset_get_minimum (bitset);
+
+  if (pos > 0)
+    {
+      gtk_selection_model_select_item (model, pos-1, TRUE);
+      gtk_widget_activate_action (GTK_WIDGET (view), "list.scroll-to-item", "u", pos-1);
+    }
+}
+
+gboolean
+ide_gtk_list_view_get_selected_row (GtkListView *view,
+                                    guint       *position)
+{
+  GtkSelectionModel *model;
+  GtkBitset *bitset;
+
+  g_return_val_if_fail (GTK_IS_LIST_VIEW (view), FALSE);
+
+  if (!(model = gtk_list_view_get_model (view)))
+    return FALSE;
+
+  bitset = gtk_selection_model_get_selection (model);
+  if (gtk_bitset_is_empty (bitset))
+    return FALSE;
+
+  *position = gtk_bitset_get_minimum (bitset);
+  return TRUE;
+}
+
+static void
+on_items_changed_cb (GListModel *model,
+                     guint       position,
+                     guint       removed,
+                     guint       added,
+                     GtkWidget  *widget)
+{
+  gboolean was_visible = gtk_widget_get_visible (widget);
+  gboolean is_visible = added > 0 || g_list_model_get_n_items (model) > 0;
+
+  if (was_visible != is_visible)
+    gtk_widget_set_visible (widget, is_visible);
+}
+
+void
+ide_gtk_widget_hide_when_empty (GtkWidget  *widget,
+                                GListModel *model)
+{
+  g_return_if_fail (GTK_IS_WIDGET (widget));
+  g_return_if_fail (G_IS_LIST_MODEL (model));
+
+  gtk_widget_set_visible (widget, g_list_model_get_n_items (model) > 0);
+  g_signal_connect_object (model,
+                           "items-changed",
+                           G_CALLBACK (on_items_changed_cb),
+                           widget,
+                           0);
+}
diff --git a/src/libide/gtk/ide-gtk.h b/src/libide/gtk/ide-gtk.h
new file mode 100644
index 000000000..6aa5bfae0
--- /dev/null
+++ b/src/libide/gtk/ide-gtk.h
@@ -0,0 +1,73 @@
+/* ide-gtk.h
+ *
+ * Copyright 2015-2022 Christian Hergert <chergert redhat com>
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+#pragma once
+
+#if !defined (IDE_GTK_INSIDE) && !defined (IDE_GTK_COMPILATION)
+# error "Only <libide-gtk.h> can be included directly."
+#endif
+
+#include <libide-core.h>
+
+#include <gtk/gtk.h>
+
+G_BEGIN_DECLS
+
+IDE_AVAILABLE_IN_ALL
+gboolean ide_gtk_show_uri_on_window           (GtkWindow        *window,
+                                               const char       *uri,
+                                               gint64            timestamp,
+                                               GError           **error);
+IDE_AVAILABLE_IN_ALL
+void     ide_gtk_window_present               (GtkWindow         *window);
+IDE_AVAILABLE_IN_ALL
+void      ide_gtk_progress_bar_start_pulsing  (GtkProgressBar    *progress);
+IDE_AVAILABLE_IN_ALL
+void      ide_gtk_progress_bar_stop_pulsing   (GtkProgressBar    *progress);
+IDE_AVAILABLE_IN_ALL
+void      ide_gtk_widget_show_with_fade       (GtkWidget         *widget);
+IDE_AVAILABLE_IN_ALL
+void      ide_gtk_widget_hide_with_fade       (GtkWidget         *widget);
+IDE_AVAILABLE_IN_ALL
+void      ide_gtk_list_store_insert_sorted    (GtkListStore      *store,
+                                               GtkTreeIter       *iter,
+                                               gconstpointer      key,
+                                               guint              compare_column,
+                                               GCompareDataFunc   compare_func,
+                                               gpointer           compare_data);
+IDE_AVAILABLE_IN_ALL
+void       ide_gtk_widget_destroyed           (GtkWidget         *widget,
+                                               GtkWidget        **location);
+IDE_AVAILABLE_IN_ALL
+char      *ide_g_time_span_to_label           (GTimeSpan          span);
+IDE_AVAILABLE_IN_ALL
+char      *ide_g_date_time_format_for_display (GDateTime         *self);
+IDE_AVAILABLE_IN_ALL
+void       ide_gtk_list_view_move_next        (GtkListView       *view);
+IDE_AVAILABLE_IN_ALL
+void       ide_gtk_list_view_move_previous    (GtkListView       *view);
+IDE_AVAILABLE_IN_ALL
+gboolean   ide_gtk_list_view_get_selected_row (GtkListView       *view,
+                                               guint             *position);
+IDE_AVAILABLE_IN_ALL
+void       ide_gtk_widget_hide_when_empty     (GtkWidget         *widget,
+                                               GListModel        *model);
+
+G_END_DECLS
diff --git a/src/libide/gtk/ide-joined-menu.c b/src/libide/gtk/ide-joined-menu.c
new file mode 100644
index 000000000..b0b844797
--- /dev/null
+++ b/src/libide/gtk/ide-joined-menu.c
@@ -0,0 +1,327 @@
+/* ide-joined-menu.c
+ *
+ * Copyright 2017-2021 Christian Hergert <chergert redhat com>
+ *
+ * This file is free software; you can redistribute it and/or modify it under
+ * the terms of the GNU Lesser General Public License as published by the Free
+ * Software Foundation; either version 3 of the License, or (at your option)
+ * any later version.
+ *
+ * This file is distributed in the hope that it will be useful, but WITHOUT
+ * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+ * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU Lesser General Public
+ * License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ * SPDX-License-Identifier: LGPL-3.0-or-later
+ */
+
+#define G_LOG_DOMAIN "ide-joined-menu"
+
+#include "config.h"
+
+#include "ide-joined-menu.h"
+
+typedef struct
+{
+  GMenuModel *model;
+  gulong      items_changed_handler;
+} Menu;
+
+struct _IdeJoinedMenu
+{
+  GMenuModel  parent_instance;
+  GArray     *menus;
+};
+
+G_DEFINE_TYPE (IdeJoinedMenu, ide_joined_menu, G_TYPE_MENU_MODEL)
+
+static void
+clear_menu (gpointer data)
+{
+  Menu *menu = data;
+
+  g_signal_handler_disconnect (menu->model, menu->items_changed_handler);
+  menu->items_changed_handler = 0;
+  g_clear_object (&menu->model);
+}
+
+static gint
+ide_joined_menu_get_offset_at_index (IdeJoinedMenu *self,
+                                     gint           index)
+{
+  gint offset = 0;
+
+  for (guint i = 0; i < index; i++)
+    offset += g_menu_model_get_n_items (g_array_index (self->menus, Menu, i).model);
+
+  return offset;
+}
+
+static gint
+ide_joined_menu_get_offset_at_model (IdeJoinedMenu *self,
+                                     GMenuModel    *model)
+{
+  gint offset = 0;
+
+  for (guint i = 0; i < self->menus->len; i++)
+    {
+      const Menu *menu = &g_array_index (self->menus, Menu, i);
+
+      if (menu->model == model)
+        break;
+
+      offset += g_menu_model_get_n_items (menu->model);
+    }
+
+  return offset;
+}
+
+static gboolean
+ide_joined_menu_is_mutable (GMenuModel *model)
+{
+  return TRUE;
+}
+
+static gint
+ide_joined_menu_get_n_items (GMenuModel *model)
+{
+  IdeJoinedMenu *self = (IdeJoinedMenu *)model;
+
+  if (self->menus->len == 0)
+    return 0;
+
+  return ide_joined_menu_get_offset_at_index (self, self->menus->len);
+}
+
+static const Menu *
+ide_joined_menu_get_item (IdeJoinedMenu *self,
+                          gint          *item_index)
+{
+  g_assert (IDE_IS_JOINED_MENU (self));
+
+  for (guint i = 0; i < self->menus->len; i++)
+    {
+      const Menu *menu = &g_array_index (self->menus, Menu, i);
+      gint n_items = g_menu_model_get_n_items (menu->model);
+
+      if (n_items > *item_index)
+        return menu;
+
+      (*item_index) -= n_items;
+    }
+
+  g_assert_not_reached ();
+
+  return NULL;
+}
+
+static void
+ide_joined_menu_get_item_attributes (GMenuModel  *model,
+                                     gint         item_index,
+                                     GHashTable **attributes)
+{
+  const Menu *menu = ide_joined_menu_get_item (IDE_JOINED_MENU (model), &item_index);
+  G_MENU_MODEL_GET_CLASS (menu->model)->get_item_attributes (menu->model, item_index, attributes);
+}
+
+static GMenuAttributeIter *
+ide_joined_menu_iterate_item_attributes (GMenuModel *model,
+                                         gint        item_index)
+{
+  const Menu *menu = ide_joined_menu_get_item (IDE_JOINED_MENU (model), &item_index);
+  return G_MENU_MODEL_GET_CLASS (menu->model)->iterate_item_attributes (menu->model, item_index);
+}
+
+static GVariant *
+ide_joined_menu_get_item_attribute_value (GMenuModel         *model,
+                                          gint                item_index,
+                                          const gchar        *attribute,
+                                          const GVariantType *expected_type)
+{
+  const Menu *menu = ide_joined_menu_get_item (IDE_JOINED_MENU (model), &item_index);
+  return G_MENU_MODEL_GET_CLASS (menu->model)->get_item_attribute_value (menu->model, item_index, attribute, 
expected_type);
+}
+
+static void
+ide_joined_menu_get_item_links (GMenuModel  *model,
+                                gint         item_index,
+                                GHashTable **links)
+{
+  const Menu *menu = ide_joined_menu_get_item (IDE_JOINED_MENU (model), &item_index);
+  G_MENU_MODEL_GET_CLASS (menu->model)->get_item_links (menu->model, item_index, links);
+}
+
+static GMenuLinkIter *
+ide_joined_menu_iterate_item_links (GMenuModel *model,
+                                    gint        item_index)
+{
+  const Menu *menu = ide_joined_menu_get_item (IDE_JOINED_MENU (model), &item_index);
+  return G_MENU_MODEL_GET_CLASS (menu->model)->iterate_item_links (menu->model, item_index);
+}
+
+static GMenuModel *
+ide_joined_menu_get_item_link (GMenuModel  *model,
+                               gint         item_index,
+                               const gchar *link)
+{
+  const Menu *menu = ide_joined_menu_get_item (IDE_JOINED_MENU (model), &item_index);
+  return G_MENU_MODEL_GET_CLASS (menu->model)->get_item_link (menu->model, item_index, link);
+}
+
+static void
+ide_joined_menu_finalize (GObject *object)
+{
+  IdeJoinedMenu *self = (IdeJoinedMenu *)object;
+
+  g_clear_pointer (&self->menus, g_array_unref);
+
+  G_OBJECT_CLASS (ide_joined_menu_parent_class)->finalize (object);
+}
+
+static void
+ide_joined_menu_class_init (IdeJoinedMenuClass *klass)
+{
+  GObjectClass *object_class = G_OBJECT_CLASS (klass);
+  GMenuModelClass *menu_model_class = G_MENU_MODEL_CLASS (klass);
+
+  object_class->finalize = ide_joined_menu_finalize;
+
+  menu_model_class->is_mutable = ide_joined_menu_is_mutable;
+  menu_model_class->get_n_items = ide_joined_menu_get_n_items;
+  menu_model_class->get_item_attributes = ide_joined_menu_get_item_attributes;
+  menu_model_class->iterate_item_attributes = ide_joined_menu_iterate_item_attributes;
+  menu_model_class->get_item_attribute_value = ide_joined_menu_get_item_attribute_value;
+  menu_model_class->get_item_links = ide_joined_menu_get_item_links;
+  menu_model_class->iterate_item_links = ide_joined_menu_iterate_item_links;
+  menu_model_class->get_item_link = ide_joined_menu_get_item_link;
+}
+
+static void
+ide_joined_menu_init (IdeJoinedMenu *self)
+{
+  self->menus = g_array_new (FALSE, FALSE, sizeof (Menu));
+  g_array_set_clear_func (self->menus, clear_menu);
+}
+
+static void
+ide_joined_menu_on_items_changed (IdeJoinedMenu *self,
+                                  guint          offset,
+                                  guint          removed,
+                                  guint          added,
+                                  GMenuModel    *model)
+{
+  g_assert (IDE_IS_JOINED_MENU (self));
+  g_assert (G_IS_MENU_MODEL (model));
+
+  offset += ide_joined_menu_get_offset_at_model (self, model);
+  g_menu_model_items_changed (G_MENU_MODEL (self), offset, removed, added);
+}
+
+IdeJoinedMenu *
+ide_joined_menu_new (void)
+{
+  return g_object_new (IDE_TYPE_JOINED_MENU, NULL);
+}
+
+static void
+ide_joined_menu_insert (IdeJoinedMenu *self,
+                        GMenuModel    *model,
+                        gint           index)
+{
+  Menu menu = { 0 };
+  gint offset;
+  gint n_items;
+
+  g_assert (IDE_IS_JOINED_MENU (self));
+  g_assert (G_IS_MENU_MODEL (model));
+  g_assert (index >= 0);
+  g_assert (index <= self->menus->len);
+
+  menu.model = g_object_ref (model);
+  menu.items_changed_handler =
+    g_signal_connect_swapped (menu.model,
+                              "items-changed",
+                              G_CALLBACK (ide_joined_menu_on_items_changed),
+                              self);
+  g_array_insert_val (self->menus, index, menu);
+
+  n_items = g_menu_model_get_n_items (model);
+  offset = ide_joined_menu_get_offset_at_index (self, index);
+  g_menu_model_items_changed (G_MENU_MODEL (self), offset, 0, n_items);
+}
+
+void
+ide_joined_menu_append_menu (IdeJoinedMenu *self,
+                             GMenuModel    *model)
+{
+
+  g_return_if_fail (IDE_IS_JOINED_MENU (self));
+  g_return_if_fail (G_MENU_MODEL (model));
+
+  ide_joined_menu_insert (self, model, self->menus->len);
+}
+
+void
+ide_joined_menu_prepend_menu (IdeJoinedMenu *self,
+                              GMenuModel    *model)
+{
+  g_return_if_fail (IDE_IS_JOINED_MENU (self));
+  g_return_if_fail (G_MENU_MODEL (model));
+
+  ide_joined_menu_insert (self, model, 0);
+}
+
+void
+ide_joined_menu_remove_index (IdeJoinedMenu *self,
+                              guint          index)
+{
+  const Menu *menu;
+  gint n_items;
+  gint offset;
+
+  g_return_if_fail (IDE_IS_JOINED_MENU (self));
+  g_return_if_fail (index < self->menus->len);
+
+  menu = &g_array_index (self->menus, Menu, index);
+
+  offset = ide_joined_menu_get_offset_at_index (self, index);
+  n_items = g_menu_model_get_n_items (menu->model);
+
+  g_array_remove_index (self->menus, index);
+
+  g_menu_model_items_changed (G_MENU_MODEL (self), offset, n_items, 0);
+}
+
+void
+ide_joined_menu_remove_menu (IdeJoinedMenu *self,
+                             GMenuModel    *model)
+{
+  g_return_if_fail (IDE_IS_JOINED_MENU (self));
+  g_return_if_fail (G_IS_MENU_MODEL (model));
+
+  for (guint i = 0; i < self->menus->len; i++)
+    {
+      if (g_array_index (self->menus, Menu, i).model == model)
+        {
+          ide_joined_menu_remove_index (self, i);
+          break;
+        }
+    }
+}
+
+/**
+ * ide_joined_menu_get_n_joined:
+ * @self: a #IdeJoinedMenu
+ *
+ * Gets the number of joined menus.
+ */
+guint
+ide_joined_menu_get_n_joined (IdeJoinedMenu *self)
+{
+  g_return_val_if_fail (IDE_IS_JOINED_MENU (self), 0);
+
+  return self->menus->len;
+}
diff --git a/src/libide/gtk/ide-joined-menu.h b/src/libide/gtk/ide-joined-menu.h
new file mode 100644
index 000000000..33f3b6ef9
--- /dev/null
+++ b/src/libide/gtk/ide-joined-menu.h
@@ -0,0 +1,53 @@
+/* ide-joined-menu-private.h
+ *
+ * Copyright 2017-2021 Christian Hergert <chergert redhat com>
+ *
+ * This file is free software; you can redistribute it and/or modify it under
+ * the terms of the GNU Lesser General Public License as published by the Free
+ * Software Foundation; either version 3 of the License, or (at your option)
+ * any later version.
+ *
+ * This file is distributed in the hope that it will be useful, but WITHOUT
+ * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+ * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU Lesser General Public
+ * License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ * SPDX-License-Identifier: LGPL-3.0-or-later
+ */
+
+#pragma once
+
+#if !defined (IDE_GTK_INSIDE) && !defined (IDE_GTK_COMPILATION)
+# error "Only <libide-gtk.h> can be included directly."
+#endif
+
+#include <libide-core.h>
+
+G_BEGIN_DECLS
+
+#define IDE_TYPE_JOINED_MENU (ide_joined_menu_get_type())
+
+IDE_AVAILABLE_IN_ALL
+G_DECLARE_FINAL_TYPE (IdeJoinedMenu, ide_joined_menu, IDE, JOINED_MENU, GMenuModel)
+
+IDE_AVAILABLE_IN_ALL
+IdeJoinedMenu *ide_joined_menu_new          (void);
+IDE_AVAILABLE_IN_ALL
+guint          ide_joined_menu_get_n_joined (IdeJoinedMenu *self);
+IDE_AVAILABLE_IN_ALL
+void           ide_joined_menu_append_menu  (IdeJoinedMenu *self,
+                                             GMenuModel    *model);
+IDE_AVAILABLE_IN_ALL
+void           ide_joined_menu_prepend_menu (IdeJoinedMenu *self,
+                                             GMenuModel    *model);
+IDE_AVAILABLE_IN_ALL
+void           ide_joined_menu_remove_menu  (IdeJoinedMenu *self,
+                                             GMenuModel    *model);
+IDE_AVAILABLE_IN_ALL
+void           ide_joined_menu_remove_index (IdeJoinedMenu *self,
+                                             guint          index);
+
+G_END_DECLS
diff --git a/src/libide/gtk/ide-menu-manager.c b/src/libide/gtk/ide-menu-manager.c
new file mode 100644
index 000000000..1800722d7
--- /dev/null
+++ b/src/libide/gtk/ide-menu-manager.c
@@ -0,0 +1,679 @@
+/* ide-menu-manager.c
+ *
+ * Copyright (C) 2015 Christian Hergert <chergert redhat com>
+ *
+ * This file is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 2.1 of the License, or (at your option) any later version.
+ *
+ * This file is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+#define G_LOG_DOMAIN "ide-menu-manager"
+
+#include "config.h"
+
+#include <gtk/gtk.h>
+#include <string.h>
+
+#include "ide-menu-manager.h"
+
+struct _IdeMenuManager
+{
+  GObject     parent_instance;
+
+  guint       last_merge_id;
+  GHashTable *models;
+};
+
+G_DEFINE_TYPE (IdeMenuManager, ide_menu_manager, G_TYPE_OBJECT)
+
+#define IDE_MENU_ATTRIBUTE_BEFORE   "before"
+#define IDE_MENU_ATTRIBUTE_AFTER    "after"
+#define IDE_MENU_ATTRIBUTE_MERGE_ID "ide-merge-id"
+
+/**
+ * IdeMenuManager:
+ *
+ * The goal of #IdeMenuManager is to simplify the process of merging multiple
+ * GtkBuilder .ui files containing menus into a single representation of the
+ * application menus. Additionally, it provides the ability to "unmerge"
+ * previously merged menus.
+ *
+ * This allows for an application to have plugins which seemlessly extends
+ * the core application menus.
+ *
+ * Implementation notes:
+ *
+ * To make this work, we don't use the GMenu instances created by a GtkBuilder
+ * instance. Instead, we create the menus ourself and recreate section and
+ * submenu links. This allows the #IdeMenuManager to be in full control of
+ * the generated menus.
+ *
+ * ide_menu_manager_get_menu_by_id() will always return a #GMenu, however
+ * that menu may contain no children until something has extended it later
+ * on during the application process.
+ */
+
+static const gchar *
+get_object_id (GObject *object)
+{
+  g_assert (G_IS_OBJECT (object));
+
+  if (GTK_IS_BUILDABLE (object))
+    return gtk_buildable_get_buildable_id (GTK_BUILDABLE (object));
+  else
+    return g_object_get_data (object, "gtk-builder-id");
+}
+
+static void
+ide_menu_manager_dispose (GObject *object)
+{
+  IdeMenuManager *self = (IdeMenuManager *)object;
+
+  g_clear_pointer (&self->models, g_hash_table_unref);
+
+  G_OBJECT_CLASS (ide_menu_manager_parent_class)->dispose (object);
+}
+
+static void
+ide_menu_manager_class_init (IdeMenuManagerClass *klass)
+{
+  GObjectClass *object_class = G_OBJECT_CLASS (klass);
+
+  object_class->dispose = ide_menu_manager_dispose;
+}
+
+static void
+ide_menu_manager_init (IdeMenuManager *self)
+{
+  self->models = g_hash_table_new_full (g_str_hash, g_str_equal, g_free, g_object_unref);
+}
+
+static gint
+find_with_attribute_string (GMenuModel  *model,
+                            const gchar *attribute,
+                            const gchar *value)
+{
+  guint n_items;
+
+  g_assert (G_IS_MENU_MODEL (model));
+  g_assert (attribute != NULL);
+  g_assert (value != NULL);
+
+  n_items = g_menu_model_get_n_items (model);
+
+  for (guint i = 0; i < n_items; i++)
+    {
+      g_autofree gchar *item_value = NULL;
+
+      if (g_menu_model_get_item_attribute (model, i, attribute, "s", &item_value) &&
+          (g_strcmp0 (value, item_value) == 0))
+        return i;
+    }
+
+  return -1;
+}
+
+static gboolean
+ide_menu_manager_menu_contains (IdeMenuManager *self,
+                                GMenu          *menu,
+                                GMenuItem      *item)
+{
+  const char *id;
+  const char *label;
+  const char *link_id;
+
+  g_assert (IDE_IS_MENU_MANAGER (self));
+  g_assert (G_IS_MENU (menu));
+  g_assert (G_IS_MENU_ITEM (item));
+
+  /* try to find match by item link */
+  if (g_menu_item_get_attribute (item, "ide-link-id", "&s", &link_id) &&
+      (find_with_attribute_string (G_MENU_MODEL (menu), "ide-link-id", link_id) >= 0))
+    return TRUE;
+
+  /* if this item has an "id" and that id is not found in the model,
+   * then assume it is different even if another item with the same label
+   * appears, as that could be an item that gets hidden.
+   */
+  if (g_menu_item_get_attribute (item, "id", "&s", &id) &&
+      !ide_str_empty0 (id) &&
+      (find_with_attribute_string (G_MENU_MODEL (menu), "id", id) < 0))
+    return FALSE;
+
+  /* try to find  match by item label */
+  if (g_menu_item_get_attribute (item, G_MENU_ATTRIBUTE_LABEL, "&s", &label) &&
+      (find_with_attribute_string (G_MENU_MODEL (menu), G_MENU_ATTRIBUTE_LABEL, label) >= 0))
+    return TRUE;
+
+  return FALSE;
+}
+
+static void
+model_copy_attributes_to_item (GMenuModel *model,
+                               gint        item_index,
+                               GMenuItem  *item)
+{
+  g_autoptr(GMenuAttributeIter) iter = NULL;
+  const gchar *attr_name;
+  GVariant *attr_value;
+
+  g_assert (G_IS_MENU_MODEL (model));
+  g_assert (item_index >= 0);
+  g_assert (G_IS_MENU_ITEM (item));
+
+  if (!(iter = g_menu_model_iterate_item_attributes (model, item_index)))
+    return;
+
+  while (g_menu_attribute_iter_get_next (iter, &attr_name, &attr_value))
+    {
+      g_menu_item_set_attribute_value (item, attr_name, attr_value);
+      g_variant_unref (attr_value);
+    }
+}
+
+static void
+model_copy_links_to_item (GMenuModel *model,
+                          guint       position,
+                          GMenuItem  *item)
+{
+  g_autoptr(GMenuLinkIter) link_iter = NULL;
+
+  g_assert (G_IS_MENU_MODEL (model));
+  g_assert (G_IS_MENU_ITEM (item));
+
+  link_iter = g_menu_model_iterate_item_links (model, position);
+
+  while (g_menu_link_iter_next (link_iter))
+    {
+      g_autoptr(GMenuModel) link_model = NULL;
+      const gchar *link_name;
+
+      link_name = g_menu_link_iter_get_name (link_iter);
+      link_model = g_menu_link_iter_get_value (link_iter);
+
+      g_menu_item_set_link (item, link_name, link_model);
+    }
+}
+
+static void
+menu_move_item_to (GMenu *menu,
+                   guint  position,
+                   guint  new_position)
+{
+  g_autoptr(GMenuItem) item = NULL;
+
+  g_assert (G_IS_MENU (menu));
+
+  item = g_menu_item_new (NULL, NULL);
+  model_copy_attributes_to_item (G_MENU_MODEL (menu), position, item);
+  model_copy_links_to_item (G_MENU_MODEL (menu), position, item);
+
+  g_menu_remove (menu, position);
+  g_menu_insert_item (menu, new_position, item);
+}
+
+static void
+ide_menu_manager_resolve_constraints (GMenu *menu)
+{
+  GMenuModel *model = (GMenuModel *)menu;
+  gint n_items;
+
+  g_assert (G_IS_MENU (menu));
+
+  n_items = (gint)g_menu_model_get_n_items (G_MENU_MODEL (menu));
+
+  /*
+   * We start iterating forwards. As we look at each row, we start
+   * again from the end working backwards to see if we need to be
+   * moved after that row.
+   *
+   * This way we know we see the furthest we might need to jump first.
+   */
+
+  for (gint i = 0; i < n_items; i++)
+    {
+      g_autofree gchar *i_after = NULL;
+
+      g_menu_model_get_item_attribute (model, i, IDE_MENU_ATTRIBUTE_AFTER, "s", &i_after);
+      if (i_after == NULL)
+        continue;
+
+      /* Work our way backwards from the end back to
+       * our current position (but not overlapping).
+       */
+      for (gint j = n_items - 1; j > i; j--)
+        {
+          g_autofree gchar *j_id = NULL;
+          g_autofree gchar *j_label = NULL;
+
+          g_menu_model_get_item_attribute (model, j, "id", "s", &j_id);
+          g_menu_model_get_item_attribute (model, j, "label", "s", &j_label);
+
+          if (ide_str_equal0 (i_after, j_id) || ide_str_equal0 (i_after, j_label))
+            {
+              /* You might think we need to place the item *AFTER*
+               * our position "j". But since we remove the row where
+               * "i" currently is, we get the proper location.
+               */
+              menu_move_item_to (menu, i, j);
+              i--;
+              break;
+            }
+        }
+    }
+
+  /*
+   * Now we need to apply the same thing but for the "before" links
+   * in our model. To do this, we also want to ensure we find the
+   * furthest jump first. So we start from the end and work our way
+   * towards the front and for each of those nodes, start from the
+   * front and work our way back.
+   */
+
+  for (gint i = n_items - 1; i >= 0; i--)
+    {
+      g_autofree gchar *i_before = NULL;
+
+      g_menu_model_get_item_attribute (model, i, IDE_MENU_ATTRIBUTE_BEFORE, "s", &i_before);
+      if (i_before == NULL)
+        continue;
+
+      /* Work our way from the front back towards our current position
+       * that would cause our position to jump.
+       */
+      for (gint j = 0; j < i; j++)
+        {
+          g_autofree gchar *j_id = NULL;
+          g_autofree gchar *j_label = NULL;
+
+          g_menu_model_get_item_attribute (model, j, "id", "s", &j_id);
+          g_menu_model_get_item_attribute (model, j, "label", "s", &j_label);
+
+          if (ide_str_equal0 (i_before, j_id) || ide_str_equal0 (i_before, j_label))
+            {
+              /*
+               * This item needs to be placed before this item we just found.
+               * Since that is the furthest we could jump, just stop
+               * afterwards.
+               */
+              menu_move_item_to (menu, i, j);
+              i++;
+              break;
+            }
+        }
+    }
+}
+
+static void
+ide_menu_manager_add_to_menu (IdeMenuManager *self,
+                              GMenu          *menu,
+                              GMenuItem      *item)
+{
+  g_assert (IDE_IS_MENU_MANAGER (self));
+  g_assert (G_IS_MENU (menu));
+  g_assert (G_IS_MENU_ITEM (item));
+
+  /*
+   * The proplem here is one that could end up being an infinite
+   * loop if we tried to resolve all the position requirements
+   * until no more position changes were required. So instead we
+   * simplify the problem into an append, and two-passes as trying
+   * to fix up the positions.
+   */
+  g_menu_append_item (menu, item);
+  ide_menu_manager_resolve_constraints (menu);
+  ide_menu_manager_resolve_constraints (menu);
+}
+
+static void
+ide_menu_manager_merge_model (IdeMenuManager *self,
+                              GMenu          *menu,
+                              GMenuModel     *model,
+                              guint           merge_id)
+{
+  guint n_items;
+
+  g_assert (IDE_IS_MENU_MANAGER (self));
+  g_assert (G_IS_MENU (menu));
+  g_assert (G_IS_MENU_MODEL (model));
+  g_assert (merge_id > 0);
+
+  /*
+   * NOTES:
+   *
+   * Instead of using g_menu_item_new_from_model(), we create our own item
+   * and resolve section/submenu links. This allows us to be in full control
+   * of all of the menu items created.
+   *
+   * We move through each item in @model. If that item does not exist within
+   * @menu, we add it taking into account %IDE_MENU_ATTRIBUTE_BEFORE and
+   * %IDE_MENU_ATTRIBUTE_AFTER.
+   */
+
+  n_items = g_menu_model_get_n_items (model);
+
+  for (guint i = 0; i < n_items; i++)
+    {
+      g_autoptr(GMenuItem) item = NULL;
+      g_autoptr(GMenuLinkIter) link_iter = NULL;
+
+      item = g_menu_item_new (NULL, NULL);
+
+      /*
+       * Copy attributes from the model. This includes, label, action,
+       * target, before, after, etc. Also set our merge-id so that we
+       * can remove the item when we are unmerged.
+       */
+      model_copy_attributes_to_item (model, i, item);
+      g_menu_item_set_attribute (item, IDE_MENU_ATTRIBUTE_MERGE_ID, "u", merge_id);
+
+      /*
+       * If this is a link, resolve it from our already created GMenu.
+       * The menu might be empty now, but it will get filled in on a
+       * followup pass for that model.
+       */
+      link_iter = g_menu_model_iterate_item_links (model, i);
+      while (g_menu_link_iter_next (link_iter))
+        {
+          g_autoptr(GMenuModel) link_model = NULL;
+          const gchar *link_name;
+          const gchar *link_id;
+          GMenuModel *internal_menu;
+
+          link_name = g_menu_link_iter_get_name (link_iter);
+          link_model = g_menu_link_iter_get_value (link_iter);
+
+          g_assert (link_name != NULL);
+          g_assert (G_IS_MENU_MODEL (link_model));
+
+          link_id = get_object_id (G_OBJECT (link_model));
+
+          if (link_id == NULL)
+            {
+              g_warning ("Link of type \"%s\" missing \"id=\". "
+                         "Merging will not be possible.",
+                         link_name);
+              continue;
+            }
+
+          internal_menu = g_hash_table_lookup (self->models, link_id);
+
+          if (internal_menu == NULL)
+            {
+              g_warning ("linked menu %s has not been created", link_id);
+              continue;
+            }
+
+          /*
+           * Save the internal link reference-id to do merging of items
+           * later on. We need to know if an item matches when we might
+           * not have a "label" to work from.
+           */
+          g_menu_item_set_attribute (item, "ide-link-id", "s", link_id);
+
+          g_menu_item_set_link (item, link_name, internal_menu);
+        }
+
+      /*
+       * If the menu already has this item, that's fine. We will populate
+       * the submenu/section links in followup merges of their GMenuModel.
+       */
+      if (ide_menu_manager_menu_contains (self, menu, item))
+        continue;
+
+      ide_menu_manager_add_to_menu (self, menu, item);
+    }
+}
+
+static void
+ide_menu_manager_merge_builder (IdeMenuManager *self,
+                                GtkBuilder     *builder,
+                                guint           merge_id)
+{
+  const GSList *iter;
+  GSList *list;
+
+  g_assert (IDE_IS_MENU_MANAGER (self));
+  g_assert (GTK_IS_BUILDER (builder));
+  g_assert (merge_id > 0);
+
+  /*
+   * NOTES:
+   *
+   * We cannot re-use any of the created GMenu from the builder as we need
+   * control over all the created GMenu. Primarily because manipulating
+   * existing GMenu is such a PITA. So instead, we create our own GMenu and
+   * resolve links manually.
+   *
+   * Since GtkBuilder requires that all menus have an "id" element, we can
+   * resolve the menu->id fairly easily. First we create our own GMenu
+   * instances so that we can always resolve them during the creation process.
+   * Then we can go through and manually resolve links as we create items.
+   *
+   * We don't need to recursively create the menus since we will come across
+   * additional GMenu instances while iterating the available objects from the
+   * GtkBuilder. This does require 2 iterations of the objects, but that is
+   * not an issue.
+   */
+
+  list = gtk_builder_get_objects (builder);
+
+  /*
+   * For every menu with an id, check to see if we already created our
+   * instance of that menu. If not, create it now so we can resolve them
+   * while building the menu links.
+   */
+  for (iter = list; iter != NULL; iter = iter->next)
+    {
+      GObject *object = iter->data;
+      const gchar *id;
+      GMenu *menu;
+
+      if (!G_IS_MENU (object))
+        continue;
+
+      if (!(id = get_object_id (object)))
+        {
+          g_warning ("menu without identifier, implausible");
+          continue;
+        }
+
+      if (!(menu = g_hash_table_lookup (self->models, id)))
+        g_hash_table_insert (self->models, g_strdup (id), g_menu_new ());
+    }
+
+  /*
+   * Now build each menu we discovered in the GtkBuilder. We do not need to
+   * build them recursively since we will pass the linked menus as we make
+   * forward progress on the GtkBuilder created objects.
+   */
+
+  for (iter = list; iter != NULL; iter = iter->next)
+    {
+      GObject *object = iter->data;
+      const gchar *id;
+      GMenu *menu;
+
+      if (!G_IS_MENU_MODEL (object))
+        continue;
+
+      if (!(id = get_object_id (object)))
+        continue;
+
+      menu = g_hash_table_lookup (self->models, id);
+
+      g_assert (G_IS_MENU (menu));
+
+      ide_menu_manager_merge_model (self, menu, G_MENU_MODEL (object), merge_id);
+    }
+
+  g_slist_free (list);
+}
+
+IdeMenuManager *
+ide_menu_manager_new (void)
+{
+  return g_object_new (IDE_TYPE_MENU_MANAGER, NULL);
+}
+
+guint
+ide_menu_manager_add_filename (IdeMenuManager  *self,
+                               const gchar     *filename,
+                               GError         **error)
+{
+  GtkBuilder *builder;
+  guint merge_id;
+
+  g_return_val_if_fail (IDE_IS_MENU_MANAGER (self), 0);
+  g_return_val_if_fail (filename != NULL, 0);
+
+  builder = gtk_builder_new ();
+
+  if (!gtk_builder_add_from_file (builder, filename, error))
+    {
+      g_object_unref (builder);
+      return 0;
+    }
+
+  merge_id = ++self->last_merge_id;
+  ide_menu_manager_merge_builder (self, builder, merge_id);
+  g_object_unref (builder);
+
+  return merge_id;
+}
+
+guint
+ide_menu_manager_merge (IdeMenuManager *self,
+                        const gchar    *menu_id,
+                        GMenuModel     *menu_model)
+{
+  GMenu *menu;
+  guint merge_id;
+
+  g_return_val_if_fail (IDE_IS_MENU_MANAGER (self), 0);
+  g_return_val_if_fail (menu_id != NULL, 0);
+  g_return_val_if_fail (G_IS_MENU_MODEL (menu_model), 0);
+
+  merge_id = ++self->last_merge_id;
+
+  if (!(menu = g_hash_table_lookup (self->models, menu_id)))
+    {
+      GMenu *new_model = g_menu_new ();
+      g_hash_table_insert (self->models, g_strdup (menu_id), new_model);
+      menu = new_model;
+    }
+
+  ide_menu_manager_merge_model (self, menu, menu_model, merge_id);
+
+  return merge_id;
+}
+
+/**
+ * ide_menu_manager_remove:
+ * @self: a #IdeMenuManager
+ * @merge_id: A previously registered merge id
+ *
+ * This removes items from menus that were added as part of a previous
+ * menu merge. Use the value returned from ide_menu_manager_merge() as
+ * the @merge_id.
+ */
+void
+ide_menu_manager_remove (IdeMenuManager *self,
+                         guint           merge_id)
+{
+  GHashTableIter iter;
+  GMenu *menu;
+
+  g_return_if_fail (IDE_IS_MENU_MANAGER (self));
+  g_return_if_fail (merge_id != 0);
+
+  g_hash_table_iter_init (&iter, self->models);
+
+  while (g_hash_table_iter_next (&iter, NULL, (gpointer *)&menu))
+    {
+      gint n_items;
+      gint i;
+
+      g_assert (G_IS_MENU (menu));
+
+      n_items = g_menu_model_get_n_items (G_MENU_MODEL (menu));
+
+      /* Iterate backward so we have a stable loop variable. */
+      for (i = n_items - 1; i >= 0; i--)
+        {
+          guint item_merge_id = 0;
+
+          if (g_menu_model_get_item_attribute (G_MENU_MODEL (menu),
+                                               i,
+                                               IDE_MENU_ATTRIBUTE_MERGE_ID,
+                                               "u", &item_merge_id))
+            {
+              if (item_merge_id == merge_id)
+                g_menu_remove (menu, i);
+            }
+        }
+    }
+}
+
+/**
+ * ide_menu_manager_get_menu_by_id:
+ *
+ * Returns: (transfer none): A #GMenu.
+ */
+GMenu *
+ide_menu_manager_get_menu_by_id (IdeMenuManager *self,
+                                 const gchar    *menu_id)
+{
+  GMenu *menu;
+
+  g_return_val_if_fail (IDE_IS_MENU_MANAGER (self), NULL);
+  g_return_val_if_fail (menu_id != NULL, NULL);
+
+  menu = g_hash_table_lookup (self->models, menu_id);
+
+  if (menu == NULL)
+    {
+      menu = g_menu_new ();
+      g_hash_table_insert (self->models, g_strdup (menu_id), menu);
+    }
+
+  return menu;
+}
+
+guint
+ide_menu_manager_add_resource (IdeMenuManager  *self,
+                               const gchar     *resource,
+                               GError         **error)
+{
+  GtkBuilder *builder;
+  guint merge_id;
+
+  g_return_val_if_fail (IDE_IS_MENU_MANAGER (self), 0);
+  g_return_val_if_fail (resource != NULL, 0);
+
+  if (g_str_has_prefix (resource, "resource://"))
+    resource += strlen ("resource://");
+
+  builder = gtk_builder_new ();
+
+  if (!gtk_builder_add_from_resource (builder, resource, error))
+    {
+      g_object_unref (builder);
+      return 0;
+    }
+
+  merge_id = ++self->last_merge_id;
+  ide_menu_manager_merge_builder (self, builder, merge_id);
+  g_object_unref (builder);
+
+  return merge_id;
+}
diff --git a/src/libide/gtk/ide-menu-manager.h b/src/libide/gtk/ide-menu-manager.h
new file mode 100644
index 000000000..c2ba702c3
--- /dev/null
+++ b/src/libide/gtk/ide-menu-manager.h
@@ -0,0 +1,57 @@
+/* ide-menu-manager.h
+ *
+ * Copyright (C) 2015 Christian Hergert <chergert redhat com>
+ *
+ * This file is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 2.1 of the License, or (at your option) any later version.
+ *
+ * This file is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+#pragma once
+
+#if !defined (IDE_GTK_INSIDE) && !defined (IDE_GTK_COMPILATION)
+# error "Only <libide-gtk.h> can be included directly."
+#endif
+
+#include <gtk/gtk.h>
+
+#include <libide-core.h>
+
+G_BEGIN_DECLS
+
+#define IDE_TYPE_MENU_MANAGER (ide_menu_manager_get_type())
+
+IDE_AVAILABLE_IN_ALL
+G_DECLARE_FINAL_TYPE (IdeMenuManager, ide_menu_manager, IDE, MENU_MANAGER, GObject)
+
+IDE_AVAILABLE_IN_ALL
+IdeMenuManager *ide_menu_manager_new            (void);
+IDE_AVAILABLE_IN_ALL
+guint           ide_menu_manager_add_filename   (IdeMenuManager  *self,
+                                                 const gchar     *filename,
+                                                 GError         **error);
+IDE_AVAILABLE_IN_ALL
+guint           ide_menu_manager_add_resource   (IdeMenuManager  *self,
+                                                 const gchar     *resource,
+                                                 GError         **error);
+IDE_AVAILABLE_IN_ALL
+guint           ide_menu_manager_merge          (IdeMenuManager  *self,
+                                                 const gchar     *menu_id,
+                                                 GMenuModel      *model);
+IDE_AVAILABLE_IN_ALL
+void            ide_menu_manager_remove         (IdeMenuManager  *self,
+                                                 guint            merge_id);
+IDE_AVAILABLE_IN_ALL
+GMenu          *ide_menu_manager_get_menu_by_id (IdeMenuManager  *self,
+                                                 const gchar     *menu_id);
+
+G_END_DECLS
diff --git a/src/libide/gtk/ide-progress-icon.c b/src/libide/gtk/ide-progress-icon.c
new file mode 100644
index 000000000..55b67a785
--- /dev/null
+++ b/src/libide/gtk/ide-progress-icon.c
@@ -0,0 +1,195 @@
+/* ide-progress-icon.c
+ *
+ * Copyright (C) 2015 Igalia S.L.
+ * Copyright (C) 2016-2017 Christian Hergert <chergert redhat com>
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+#define G_LOG_DOMAIN "ide-progress-icon"
+
+#include "config.h"
+
+#define _USE_MATH_DEFINES
+#include <math.h>
+
+#include "ide-progress-icon.h"
+
+struct _IdeProgressIcon
+{
+  GtkDrawingArea parent_instance;
+  gdouble        progress;
+};
+
+G_DEFINE_TYPE (IdeProgressIcon, ide_progress_icon, GTK_TYPE_DRAWING_AREA)
+
+enum {
+  PROP_0,
+  PROP_PROGRESS,
+  N_PROPS
+};
+
+static GParamSpec *properties [N_PROPS];
+
+static void
+ide_progress_icon_draw (GtkDrawingArea *area,
+                        cairo_t        *cr,
+                        int             width,
+                        int             height,
+                        gpointer        user_data)
+{
+  IdeProgressIcon *self = (IdeProgressIcon *)area;
+  GtkStyleContext *style_context;
+  GdkRGBA rgba;
+  gdouble alpha;
+
+  g_assert (IDE_IS_PROGRESS_ICON (self));
+  g_assert (cr != NULL);
+
+  style_context = gtk_widget_get_style_context (GTK_WIDGET (area));
+  gtk_style_context_get_color (style_context, &rgba);
+
+  alpha = rgba.alpha;
+  rgba.alpha = 0.15;
+  gdk_cairo_set_source_rgba (cr, &rgba);
+
+  cairo_arc (cr,
+             width / 2,
+             height / 2,
+             width / 2,
+             0.0,
+             2 * M_PI);
+  cairo_fill (cr);
+
+  if (self->progress > 0.0)
+    {
+      rgba.alpha = alpha;
+      gdk_cairo_set_source_rgba (cr, &rgba);
+
+      cairo_arc (cr,
+                 width / 2,
+                 height / 2,
+                 width / 2,
+                 (-.5 * M_PI),
+                 (2 * self->progress * M_PI) - (.5 * M_PI));
+
+      if (self->progress != 1.0)
+        {
+          cairo_line_to (cr, width / 2, height / 2);
+          cairo_line_to (cr, width / 2, 0);
+        }
+
+      cairo_fill (cr);
+    }
+}
+
+static void
+ide_progress_icon_get_property (GObject    *object,
+                                guint       prop_id,
+                                GValue     *value,
+                                GParamSpec *pspec)
+{
+  IdeProgressIcon *self = IDE_PROGRESS_ICON (object);
+
+  switch (prop_id)
+    {
+    case PROP_PROGRESS:
+      g_value_set_double (value, ide_progress_icon_get_progress (self));
+      break;
+
+    default:
+      G_OBJECT_WARN_INVALID_PROPERTY_ID (object, prop_id, pspec);
+    }
+}
+
+static void
+ide_progress_icon_set_property (GObject      *object,
+                                guint         prop_id,
+                                const GValue *value,
+                                GParamSpec   *pspec)
+{
+  IdeProgressIcon *self = IDE_PROGRESS_ICON (object);
+
+  switch (prop_id)
+    {
+    case PROP_PROGRESS:
+      ide_progress_icon_set_progress (self, g_value_get_double (value));
+      break;
+
+    default:
+      G_OBJECT_WARN_INVALID_PROPERTY_ID (object, prop_id, pspec);
+    }
+}
+
+static void
+ide_progress_icon_class_init (IdeProgressIconClass *klass)
+{
+  GObjectClass *object_class = G_OBJECT_CLASS (klass);
+
+  object_class->get_property = ide_progress_icon_get_property;
+  object_class->set_property = ide_progress_icon_set_property;
+
+  properties [PROP_PROGRESS] =
+    g_param_spec_double ("progress",
+                         "Progress",
+                         "Progress",
+                         0.0,
+                         1.0,
+                         0.0,
+                         (G_PARAM_READWRITE | G_PARAM_STATIC_STRINGS));
+
+  g_object_class_install_properties (object_class, N_PROPS, properties);
+}
+
+static void
+ide_progress_icon_init (IdeProgressIcon *icon)
+{
+  g_object_set (icon, "width-request", 16, "height-request", 16, NULL);
+  gtk_widget_set_valign (GTK_WIDGET (icon), GTK_ALIGN_CENTER);
+  gtk_widget_set_halign (GTK_WIDGET (icon), GTK_ALIGN_CENTER);
+
+  gtk_drawing_area_set_draw_func (GTK_DRAWING_AREA (icon),
+                                  ide_progress_icon_draw,
+                                  NULL, NULL);
+}
+
+GtkWidget *
+ide_progress_icon_new (void)
+{
+  return g_object_new (IDE_TYPE_PROGRESS_ICON, NULL);
+}
+
+gdouble
+ide_progress_icon_get_progress (IdeProgressIcon *self)
+{
+  g_return_val_if_fail (IDE_IS_PROGRESS_ICON (self), 0.0);
+
+  return self->progress;
+}
+
+void
+ide_progress_icon_set_progress (IdeProgressIcon *self,
+                                gdouble          progress)
+{
+  g_return_if_fail (IDE_IS_PROGRESS_ICON (self));
+
+  progress = CLAMP (progress, 0.0, 1.0);
+
+  if (self->progress != progress)
+    {
+      self->progress = progress;
+      g_object_notify_by_pspec (G_OBJECT (self), properties [PROP_PROGRESS]);
+      gtk_widget_queue_draw (GTK_WIDGET (self));
+    }
+}
diff --git a/src/libide/gtk/ide-progress-icon.h b/src/libide/gtk/ide-progress-icon.h
new file mode 100644
index 000000000..078685483
--- /dev/null
+++ b/src/libide/gtk/ide-progress-icon.h
@@ -0,0 +1,44 @@
+/* ide-progress-icon.h
+ *
+ * Copyright (C) 2016 Christian Hergert <chergert redhat com>
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+#pragma once
+
+#if !defined (IDE_GTK_INSIDE) && !defined (IDE_GTK_COMPILATION)
+# error "Only <libide-gtk.h> can be included directly."
+#endif
+
+#include <gtk/gtk.h>
+
+#include <libide-core.h>
+
+G_BEGIN_DECLS
+
+#define IDE_TYPE_PROGRESS_ICON (ide_progress_icon_get_type())
+
+IDE_AVAILABLE_IN_ALL
+G_DECLARE_FINAL_TYPE (IdeProgressIcon, ide_progress_icon, IDE, PROGRESS_ICON, GtkDrawingArea)
+
+IDE_AVAILABLE_IN_ALL
+GtkWidget *ide_progress_icon_new          (void);
+IDE_AVAILABLE_IN_ALL
+gdouble    ide_progress_icon_get_progress (IdeProgressIcon *self);
+IDE_AVAILABLE_IN_ALL
+void       ide_progress_icon_set_progress (IdeProgressIcon *self,
+                                           gdouble          progress);
+
+G_END_DECLS
diff --git a/src/libide/gtk/ide-radio-box.c b/src/libide/gtk/ide-radio-box.c
new file mode 100644
index 000000000..0d452adbe
--- /dev/null
+++ b/src/libide/gtk/ide-radio-box.c
@@ -0,0 +1,379 @@
+/* ide-radio-box.c
+ *
+ * Copyright (C) 2016 Christian Hergert <chergert redhat com>
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+#define G_LOG_DOMAIN "ide-radio-box"
+
+#include "config.h"
+
+#include "ide-radio-box.h"
+
+#define N_PER_ROW 4
+
+typedef struct
+{
+  gchar           *id;
+  gchar           *text;
+  GtkToggleButton *button;
+} IdeRadioBoxItem;
+
+struct _IdeRadioBox
+{
+  GtkWidget      parent;
+
+  GArray        *items;
+  gchar         *active_id;
+
+  GtkBox        *vbox;
+  GtkBox        *hbox;
+  GtkRevealer   *revealer;
+
+  guint          has_more : 1;
+};
+
+G_DEFINE_FINAL_TYPE (IdeRadioBox, ide_radio_box, GTK_TYPE_WIDGET)
+
+enum {
+  PROP_0,
+  PROP_ACTIVE_ID,
+  PROP_HAS_MORE,
+  PROP_SHOW_MORE,
+  N_PROPS
+};
+
+enum {
+  CHANGED,
+  N_SIGNALS
+};
+
+static GParamSpec *properties [N_PROPS];
+static guint signals [N_SIGNALS];
+
+static gboolean
+ide_radio_box_get_has_more (IdeRadioBox *self)
+{
+  g_return_val_if_fail (IDE_IS_RADIO_BOX (self), FALSE);
+
+  return self->has_more;
+}
+
+static gboolean
+ide_radio_box_get_show_more (IdeRadioBox *self)
+{
+  g_return_val_if_fail (IDE_IS_RADIO_BOX (self), FALSE);
+
+  return gtk_revealer_get_reveal_child (self->revealer);
+}
+
+static void
+ide_radio_box_set_show_more (IdeRadioBox *self,
+                             gboolean     show_more)
+{
+  g_return_if_fail (IDE_IS_RADIO_BOX (self));
+
+  gtk_revealer_set_reveal_child (self->revealer, show_more);
+}
+
+static void
+ide_radio_box_item_clear (IdeRadioBoxItem *item)
+{
+  g_free (item->id);
+  g_free (item->text);
+}
+
+static void
+ide_radio_box_dispose (GObject *object)
+{
+  IdeRadioBox *self = (IdeRadioBox *)object;
+  GtkWidget *child;
+
+  while (self->items->len > 0)
+    {
+      g_autofree char *id = g_strdup (g_array_index (self->items, IdeRadioBoxItem, 0).id);
+
+      ide_radio_box_remove_item (self, id);
+    }
+
+  while ((child = gtk_widget_get_first_child (GTK_WIDGET (self))))
+    gtk_widget_unparent (child);
+
+  G_OBJECT_CLASS (ide_radio_box_parent_class)->dispose (object);
+}
+
+static void
+ide_radio_box_finalize (GObject *object)
+{
+  IdeRadioBox *self = (IdeRadioBox *)object;
+
+  g_clear_pointer (&self->items, g_array_unref);
+  g_clear_pointer (&self->active_id, g_free);
+
+  G_OBJECT_CLASS (ide_radio_box_parent_class)->finalize (object);
+}
+
+static void
+ide_radio_box_get_property (GObject    *object,
+                            guint       prop_id,
+                            GValue     *value,
+                            GParamSpec *pspec)
+{
+  IdeRadioBox *self = IDE_RADIO_BOX (object);
+
+  switch (prop_id)
+    {
+    case PROP_ACTIVE_ID:
+      g_value_set_string (value, ide_radio_box_get_active_id (self));
+      break;
+
+    case PROP_HAS_MORE:
+      g_value_set_boolean (value, ide_radio_box_get_has_more (self));
+      break;
+
+    case PROP_SHOW_MORE:
+      g_value_set_boolean (value, ide_radio_box_get_show_more (self));
+      break;
+
+    default:
+      G_OBJECT_WARN_INVALID_PROPERTY_ID (object, prop_id, pspec);
+    }
+}
+
+static void
+ide_radio_box_set_property (GObject      *object,
+                            guint         prop_id,
+                            const GValue *value,
+                            GParamSpec   *pspec)
+{
+  IdeRadioBox *self = IDE_RADIO_BOX (object);
+
+  switch (prop_id)
+    {
+    case PROP_ACTIVE_ID:
+      ide_radio_box_set_active_id (self, g_value_get_string (value));
+      break;
+
+    case PROP_SHOW_MORE:
+      ide_radio_box_set_show_more (self, g_value_get_boolean (value));
+      break;
+
+    default:
+      G_OBJECT_WARN_INVALID_PROPERTY_ID (object, prop_id, pspec);
+    }
+}
+
+static void
+ide_radio_box_class_init (IdeRadioBoxClass *klass)
+{
+  GObjectClass *object_class = G_OBJECT_CLASS (klass);
+  GtkWidgetClass *widget_class = GTK_WIDGET_CLASS (klass);
+
+  object_class->dispose = ide_radio_box_dispose;
+  object_class->finalize = ide_radio_box_finalize;
+  object_class->get_property = ide_radio_box_get_property;
+  object_class->set_property = ide_radio_box_set_property;
+
+  properties [PROP_ACTIVE_ID] =
+    g_param_spec_string ("active-id",
+                         "Active Id",
+                         "Active Id",
+                         NULL,
+                         (G_PARAM_READWRITE | G_PARAM_STATIC_STRINGS));
+
+  properties [PROP_HAS_MORE] =
+    g_param_spec_boolean ("has-more",
+                         "Has More",
+                         "Has more items to view",
+                         FALSE,
+                         (G_PARAM_READABLE | G_PARAM_STATIC_STRINGS));
+
+  properties [PROP_SHOW_MORE] =
+    g_param_spec_boolean ("show-more",
+                          "Show More",
+                          "Show additional items",
+                          FALSE,
+                          (G_PARAM_READWRITE | G_PARAM_STATIC_STRINGS));
+
+  g_object_class_install_properties (object_class, N_PROPS, properties);
+
+  signals [CHANGED] =
+    g_signal_new ("changed", G_TYPE_FROM_CLASS (klass),
+                  G_SIGNAL_RUN_LAST,
+                  0, NULL, NULL, NULL, G_TYPE_NONE, 0);
+
+  gtk_widget_class_set_css_name (widget_class, "radiobox");
+  gtk_widget_class_set_layout_manager_type (widget_class, GTK_TYPE_BIN_LAYOUT);
+}
+
+static void
+ide_radio_box_init (IdeRadioBox *self)
+{
+  g_autoptr(GSimpleActionGroup) group = g_simple_action_group_new ();
+  g_autoptr(GPropertyAction) action = NULL;
+  GtkWidget *vbox;
+
+  /* GPropertyAction doesn't like NULL strings */
+  self->active_id = g_strdup ("");
+
+  self->items = g_array_new (FALSE, FALSE, sizeof (IdeRadioBoxItem));
+  g_array_set_clear_func (self->items, (GDestroyNotify)ide_radio_box_item_clear);
+
+  vbox = g_object_new (GTK_TYPE_BOX,
+                       "orientation", GTK_ORIENTATION_VERTICAL,
+                       "visible", TRUE,
+                       NULL);
+  gtk_widget_set_parent (vbox, GTK_WIDGET (self));
+
+  self->hbox = g_object_new (GTK_TYPE_BOX,
+                             "orientation", GTK_ORIENTATION_HORIZONTAL,
+                             "visible", TRUE,
+                             NULL);
+  gtk_style_context_add_class (gtk_widget_get_style_context (GTK_WIDGET (self->hbox)), "linked");
+  gtk_box_append (GTK_BOX (vbox), GTK_WIDGET (self->hbox));
+
+  self->revealer = g_object_new (GTK_TYPE_REVEALER,
+                                 "reveal-child", FALSE,
+                                 "visible", TRUE,
+                                 NULL);
+  gtk_box_append (GTK_BOX (vbox), GTK_WIDGET (self->revealer));
+
+  self->vbox = g_object_new (GTK_TYPE_BOX,
+                             "orientation", GTK_ORIENTATION_VERTICAL,
+                             "margin-top", 12,
+                             "spacing", 12,
+                             "visible", TRUE,
+                             NULL);
+  gtk_revealer_set_child (self->revealer, GTK_WIDGET (self->vbox));
+
+  action = g_property_action_new ("active", self, "active-id");
+  g_action_map_add_action (G_ACTION_MAP (group), G_ACTION (action));
+  gtk_widget_insert_action_group (GTK_WIDGET (self), "radiobox", G_ACTION_GROUP (group));
+}
+
+void
+ide_radio_box_remove_item (IdeRadioBox *self,
+                           const gchar *id)
+{
+  g_return_if_fail (IDE_IS_RADIO_BOX (self));
+  g_return_if_fail (id != NULL);
+
+  for (guint i = 0; i < self->items->len; i++)
+    {
+      IdeRadioBoxItem *item = &g_array_index (self->items, IdeRadioBoxItem, i);
+
+      if (g_strcmp0 (id, item->id) == 0)
+        {
+          GtkToggleButton *button = item->button;
+          GtkWidget *parent = gtk_widget_get_parent (GTK_WIDGET (button));
+
+          g_array_remove_index_fast (self->items, i);
+          gtk_box_remove (GTK_BOX (parent), GTK_WIDGET (button));
+
+          break;
+        }
+    }
+}
+
+void
+ide_radio_box_add_item (IdeRadioBox *self,
+                        const gchar *id,
+                        const gchar *text)
+{
+  IdeRadioBoxItem item = { 0 };
+  guint precount;
+
+  g_return_if_fail (IDE_IS_RADIO_BOX (self));
+  g_return_if_fail (id != NULL);
+  g_return_if_fail (text != NULL);
+
+  precount = self->items->len;
+
+  for (guint i = 0; i < precount; ++i)
+    {
+      /* Avoid duplicate items */
+      if (!g_strcmp0 (g_array_index (self->items, IdeRadioBoxItem, i).id, id))
+        return;
+    }
+
+  item.id = g_strdup (id);
+  item.text = g_strdup (text);
+  item.button = g_object_new (GTK_TYPE_TOGGLE_BUTTON,
+                              "active", (g_strcmp0 (id, self->active_id) == 0),
+                              "action-name", "radiobox.active",
+                              "action-target", g_variant_new_string (id),
+                              "hexpand", TRUE,
+                              "label", text,
+                              "visible", TRUE,
+                              NULL);
+
+  g_array_append_val (self->items, item);
+
+  if (precount > 0 && (precount % N_PER_ROW) == 0)
+    {
+      gboolean show_more = ide_radio_box_get_show_more (self);
+      gboolean visible = !self->has_more || show_more;
+
+      self->has_more = self->items->len > N_PER_ROW;
+      self->hbox = g_object_new (GTK_TYPE_BOX,
+                                 "orientation", GTK_ORIENTATION_HORIZONTAL,
+                                 "visible", visible,
+                                 NULL);
+      gtk_style_context_add_class (gtk_widget_get_style_context (GTK_WIDGET (self->hbox)), "linked");
+      gtk_box_append (GTK_BOX (self->vbox), GTK_WIDGET (self->hbox));
+    }
+
+  gtk_box_append (GTK_BOX (self->hbox), GTK_WIDGET (item.button));
+
+  g_object_notify_by_pspec (G_OBJECT (self), properties [PROP_HAS_MORE]);
+
+  /* If this is the first item and no active id has been set,
+   * then go ahead and set the active item to this one.
+   */
+  if (self->items->len == 1 && (!self->active_id || !*self->active_id))
+    ide_radio_box_set_active_id (self, id);
+}
+
+void
+ide_radio_box_set_active_id (IdeRadioBox *self,
+                             const gchar *id)
+{
+  g_return_if_fail (IDE_IS_RADIO_BOX (self));
+
+  if (id == NULL)
+    id = "";
+
+  if (g_strcmp0 (id, self->active_id) != 0)
+    {
+      g_free (self->active_id);
+      self->active_id = g_strdup (id);
+      g_object_notify_by_pspec (G_OBJECT (self), properties [PROP_ACTIVE_ID]);
+      g_signal_emit (self, signals [CHANGED], 0);
+    }
+}
+
+const gchar *
+ide_radio_box_get_active_id (IdeRadioBox *self)
+{
+  g_return_val_if_fail (IDE_IS_RADIO_BOX (self), NULL);
+
+  return self->active_id;
+}
+
+GtkWidget *
+ide_radio_box_new (void)
+{
+  return g_object_new (IDE_TYPE_RADIO_BOX, NULL);
+}
diff --git a/src/libide/gtk/ide-radio-box.h b/src/libide/gtk/ide-radio-box.h
new file mode 100644
index 000000000..20cb33da8
--- /dev/null
+++ b/src/libide/gtk/ide-radio-box.h
@@ -0,0 +1,51 @@
+/* ide-radio-box.h
+ *
+ * Copyright (C) 2016-2022 Christian Hergert <chergert redhat com>
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+#pragma once
+
+#if !defined (IDE_GTK_INSIDE) && !defined (IDE_GTK_COMPILATION)
+# error "Only <libide-gtk.h> can be included directly."
+#endif
+
+#include <gtk/gtk.h>
+
+#include <libide-core.h>
+
+G_BEGIN_DECLS
+
+#define IDE_TYPE_RADIO_BOX (ide_radio_box_get_type())
+
+IDE_AVAILABLE_IN_ALL
+G_DECLARE_FINAL_TYPE (IdeRadioBox, ide_radio_box, IDE, RADIO_BOX, GtkWidget)
+
+IDE_AVAILABLE_IN_ALL
+GtkWidget   *ide_radio_box_new           (void);
+IDE_AVAILABLE_IN_ALL
+void         ide_radio_box_add_item      (IdeRadioBox *self,
+                                          const gchar *id,
+                                          const gchar *text);
+IDE_AVAILABLE_IN_ALL
+void         ide_radio_box_remove_item   (IdeRadioBox *self,
+                                          const gchar *id);
+IDE_AVAILABLE_IN_ALL
+const gchar *ide_radio_box_get_active_id (IdeRadioBox *self);
+IDE_AVAILABLE_IN_ALL
+void         ide_radio_box_set_active_id (IdeRadioBox *self,
+                                          const gchar *id);
+
+G_END_DECLS
diff --git a/src/libide/gtk/ide-search-entry.c b/src/libide/gtk/ide-search-entry.c
new file mode 100644
index 000000000..7aa6b211c
--- /dev/null
+++ b/src/libide/gtk/ide-search-entry.c
@@ -0,0 +1,230 @@
+/* ide-search-entry.c
+ *
+ * Copyright 2021-2022 Christian Hergert <chergert redhat com>
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+#define G_LOG_DOMAIN "ide-search-entry"
+
+#include "config.h"
+
+#include <glib/gi18n.h>
+
+#include "ide-search-entry.h"
+
+struct _IdeSearchEntry
+{
+  GtkWidget  parent_instance;
+
+  GtkText   *text;
+  GtkLabel  *info;
+
+  guint      occurrence_count;
+  int        occurrence_position;
+};
+
+static void editable_iface_init (GtkEditableInterface *iface);
+
+G_DEFINE_TYPE_WITH_CODE (IdeSearchEntry, ide_search_entry, GTK_TYPE_WIDGET,
+                         G_IMPLEMENT_INTERFACE (GTK_TYPE_EDITABLE, editable_iface_init))
+
+GtkWidget *
+ide_search_entry_new (void)
+{
+  return g_object_new (IDE_TYPE_SEARCH_ENTRY, NULL);
+}
+
+static gboolean
+ide_search_entry_grab_focus (GtkWidget *widget)
+{
+  return gtk_widget_grab_focus (GTK_WIDGET (IDE_SEARCH_ENTRY (widget)->text));
+}
+
+static void
+on_text_activate_cb (IdeSearchEntry *self,
+                     GtkText           *text)
+{
+  g_assert (IDE_IS_SEARCH_ENTRY (self));
+  g_assert (GTK_IS_TEXT (text));
+
+  gtk_widget_activate_action (GTK_WIDGET (self), "search.move-next", "b", FALSE);
+}
+
+static void
+on_text_notify_cb (IdeSearchEntry *self,
+                   GParamSpec        *pspec,
+                   GtkText           *text)
+{
+  GObjectClass *klass;
+
+  g_assert (IDE_IS_SEARCH_ENTRY (self));
+  g_assert (GTK_IS_TEXT (text));
+
+  klass = G_OBJECT_GET_CLASS (self);
+  pspec = g_object_class_find_property (klass, pspec->name);
+
+  if (pspec != NULL)
+    g_object_notify_by_pspec (G_OBJECT (self), pspec);
+}
+
+static void
+ide_search_entry_dispose (GObject *object)
+{
+  IdeSearchEntry *self = (IdeSearchEntry *)object;
+  GtkWidget *child;
+
+  self->text = NULL;
+  self->info = NULL;
+
+  while ((child = gtk_widget_get_first_child (GTK_WIDGET (self))))
+    gtk_widget_unparent (child);
+
+  G_OBJECT_CLASS (ide_search_entry_parent_class)->dispose (object);
+}
+
+static void
+ide_search_entry_get_property (GObject    *object,
+                               guint       prop_id,
+                               GValue     *value,
+                               GParamSpec *pspec)
+{
+  if (gtk_editable_delegate_get_property (object, prop_id, value, pspec))
+    return;
+
+  G_OBJECT_WARN_INVALID_PROPERTY_ID (object, prop_id, pspec);
+}
+
+static void
+ide_search_entry_set_property (GObject      *object,
+                               guint         prop_id,
+                               const GValue *value,
+                               GParamSpec   *pspec)
+{
+  if (gtk_editable_delegate_set_property (object, prop_id, value, pspec))
+    return;
+
+  G_OBJECT_WARN_INVALID_PROPERTY_ID (object, prop_id, pspec);
+}
+
+static void
+ide_search_entry_class_init (IdeSearchEntryClass *klass)
+{
+  GObjectClass *object_class = G_OBJECT_CLASS (klass);
+  GtkWidgetClass *widget_class = GTK_WIDGET_CLASS (klass);
+
+  object_class->dispose = ide_search_entry_dispose;
+  object_class->get_property = ide_search_entry_get_property;
+  object_class->set_property = ide_search_entry_set_property;
+
+  widget_class->grab_focus = ide_search_entry_grab_focus;
+
+  gtk_editable_install_properties (object_class, 1);
+
+  gtk_widget_class_set_layout_manager_type (widget_class, GTK_TYPE_BOX_LAYOUT);
+  gtk_widget_class_set_css_name (widget_class, "entry");
+  gtk_widget_class_set_accessible_role (widget_class, GTK_ACCESSIBLE_ROLE_TEXT_BOX);
+  gtk_widget_class_set_template_from_resource (widget_class, "/org/gnome/libide-gtk/ide-search-entry.ui");
+  gtk_widget_class_bind_template_child (widget_class, IdeSearchEntry, info);
+  gtk_widget_class_bind_template_child (widget_class, IdeSearchEntry, text);
+  gtk_widget_class_bind_template_callback (widget_class, on_text_activate_cb);
+  gtk_widget_class_bind_template_callback (widget_class, on_text_notify_cb);
+
+  gtk_widget_class_add_binding_action (widget_class, GDK_KEY_g, GDK_CONTROL_MASK|GDK_SHIFT_MASK, 
"search.move-previous", "b", FALSE);
+  gtk_widget_class_add_binding_action (widget_class, GDK_KEY_g, GDK_CONTROL_MASK, "search.move-next", "b", 
FALSE);
+  gtk_widget_class_add_binding_action (widget_class, GDK_KEY_Down, 0, "search.move-next", "b", FALSE);
+  gtk_widget_class_add_binding_action (widget_class, GDK_KEY_Up, 0, "search.move-previous", "b", FALSE);
+  gtk_widget_class_add_binding_action (widget_class, GDK_KEY_Return, 0, "search.move-next", "b", FALSE);
+  gtk_widget_class_add_binding_action (widget_class, GDK_KEY_KP_Enter, 0, "search.move-next", "b", FALSE);
+  gtk_widget_class_add_binding_action (widget_class, GDK_KEY_Return, GDK_SHIFT_MASK, "search.move-previous", 
"b", FALSE);
+  gtk_widget_class_add_binding_action (widget_class, GDK_KEY_KP_Enter, GDK_SHIFT_MASK, 
"search.move-previous", "b", FALSE);
+  gtk_widget_class_add_binding_action (widget_class, GDK_KEY_Return, GDK_CONTROL_MASK, "search.move-next", 
"b", TRUE);
+  gtk_widget_class_add_binding_action (widget_class, GDK_KEY_KP_Enter, GDK_CONTROL_MASK, "search.move-next", 
"b", TRUE);
+  gtk_widget_class_add_binding_action (widget_class, GDK_KEY_Return, GDK_CONTROL_MASK|GDK_SHIFT_MASK, 
"search.move-previous", "b", TRUE);
+  gtk_widget_class_add_binding_action (widget_class, GDK_KEY_KP_Enter, GDK_CONTROL_MASK|GDK_SHIFT_MASK, 
"search.move-previous", "b", TRUE);
+}
+
+static void
+ide_search_entry_init (IdeSearchEntry *self)
+{
+  cairo_font_options_t *options;
+
+  self->occurrence_position = -1;
+
+  gtk_widget_init_template (GTK_WIDGET (self));
+
+  options = cairo_font_options_create ();
+  cairo_font_options_set_variations (options, "tnum");
+  gtk_widget_set_font_options (GTK_WIDGET (self->info), options);
+  cairo_font_options_destroy (options);
+}
+
+static GtkEditable *
+ide_search_entry_get_delegate (GtkEditable *editable)
+{
+  return GTK_EDITABLE (IDE_SEARCH_ENTRY (editable)->text);
+}
+
+static void
+editable_iface_init (GtkEditableInterface *iface)
+{
+  iface->get_delegate = ide_search_entry_get_delegate;
+}
+
+static void
+ide_search_entry_update_position (IdeSearchEntry *self)
+{
+  g_assert (IDE_IS_SEARCH_ENTRY (self));
+
+  if (self->occurrence_count == 0)
+    {
+      gtk_label_set_label (self->info, NULL);
+    }
+  else
+    {
+      /* translators: the first %u is replaced with the current position, the second with the number of 
search results */
+      g_autofree char *str = g_strdup_printf (_("%u of %u"), MAX (0, self->occurrence_position), 
self->occurrence_count);
+      gtk_label_set_label (self->info, str);
+    }
+}
+
+void
+ide_search_entry_set_occurrence_count (IdeSearchEntry *self,
+                                       guint           occurrence_count)
+{
+  g_assert (IDE_IS_SEARCH_ENTRY (self));
+
+  if (self->occurrence_count != occurrence_count)
+    {
+      self->occurrence_count = occurrence_count;
+      ide_search_entry_update_position (self);
+    }
+}
+
+void
+ide_search_entry_set_occurrence_position (IdeSearchEntry *self,
+                                          int             occurrence_position)
+{
+  g_assert (IDE_IS_SEARCH_ENTRY (self));
+
+  occurrence_position = MAX (-1, occurrence_position);
+
+  if (self->occurrence_position != occurrence_position)
+    {
+      self->occurrence_position = occurrence_position;
+      ide_search_entry_update_position (self);
+    }
+}
diff --git a/src/libide/gtk/ide-search-entry.h b/src/libide/gtk/ide-search-entry.h
new file mode 100644
index 000000000..d4101e4be
--- /dev/null
+++ b/src/libide/gtk/ide-search-entry.h
@@ -0,0 +1,47 @@
+/* ide-search-entry.h
+ *
+ * Copyright 2021-2022 Christian Hergert <chergert redhat com>
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+#pragma once
+
+#if !defined (IDE_GTK_INSIDE) && !defined (IDE_GTK_COMPILATION)
+# error "Only <libide-gtk.h> can be included directly."
+#endif
+
+#include <gtk/gtk.h>
+
+#include <libide-core.h>
+
+G_BEGIN_DECLS
+
+#define IDE_TYPE_SEARCH_ENTRY (ide_search_entry_get_type())
+
+IDE_AVAILABLE_IN_ALL
+G_DECLARE_FINAL_TYPE (IdeSearchEntry, ide_search_entry, IDE, SEARCH_ENTRY, GtkWidget)
+
+IDE_AVAILABLE_IN_ALL
+GtkWidget *ide_search_entry_new                     (void);
+IDE_AVAILABLE_IN_ALL
+void       ide_search_entry_set_occurrence_count    (IdeSearchEntry *self,
+                                                     guint           occurrence_count);
+IDE_AVAILABLE_IN_ALL
+void       ide_search_entry_set_occurrence_position (IdeSearchEntry *self,
+                                                     int             occurrence_position);
+
+G_END_DECLS
diff --git a/src/libide/gtk/ide-search-entry.ui b/src/libide/gtk/ide-search-entry.ui
new file mode 100644
index 000000000..7bcb1c30c
--- /dev/null
+++ b/src/libide/gtk/ide-search-entry.ui
@@ -0,0 +1,30 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<interface>
+  <requires lib="gtk" version="4.0"/>
+  <template class="IdeSearchEntry" parent="GtkWidget">
+    <property name="width-request">225</property>
+    <child>
+      <object class="GtkImage">
+        <property name="icon-name">edit-find-symbolic</property>
+      </object>
+    </child>
+    <child>
+      <object class="GtkText" id="text">
+        <property name="hexpand">true</property>
+        <property name="vexpand">true</property>
+        <property name="width-chars">12</property>
+        <property name="max-width-chars">12</property>
+        <signal name="notify" handler="on_text_notify_cb" swapped="true"/>
+        <signal name="activate" handler="on_text_activate_cb" swapped="true"/>
+      </object>
+    </child>
+    <child>
+      <object class="GtkLabel" id="info">
+        <property name="xalign">1</property>
+        <attributes>
+          <attribute name="foreground-alpha" value="33000"/>
+        </attributes>
+      </object>
+    </child>
+  </template>
+</interface>
diff --git a/src/libide/gtk/ide-shortcut-accel-dialog.c b/src/libide/gtk/ide-shortcut-accel-dialog.c
new file mode 100644
index 000000000..b0aeffc83
--- /dev/null
+++ b/src/libide/gtk/ide-shortcut-accel-dialog.c
@@ -0,0 +1,419 @@
+/* ide-shortcut-accel-dialog.c
+ *
+ * Copyright (C) 2016 Endless, Inc
+ *           (C) 2017 Christian Hergert
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ * Authors: Georges Basile Stavracas Neto <georges stavracas gmail com>
+ *          Christian Hergert <chergert redhat com>
+ */
+
+#define G_LOG_DOMAIN "ide-shortcut-accel-dialog"
+
+#include "config.h"
+
+#include <glib/gi18n.h>
+
+#include "ide-shortcut-accel-dialog.h"
+
+struct _IdeShortcutAccelDialog
+{
+  GtkDialog             parent_instance;
+
+  GtkStack             *stack;
+  GtkLabel             *display_label;
+  GtkShortcutLabel     *display_shortcut;
+  GtkLabel             *selection_label;
+
+  char                 *shortcut_title;
+
+  guint                 keyval;
+  GdkModifierType       modifier;
+
+  guint                 first_modifier;
+
+  guint                 editing : 1;
+};
+
+enum {
+  PROP_0,
+  PROP_ACCELERATOR,
+  PROP_SHORTCUT_TITLE,
+  N_PROPS
+};
+
+G_DEFINE_FINAL_TYPE (IdeShortcutAccelDialog, ide_shortcut_accel_dialog, GTK_TYPE_DIALOG)
+
+static GParamSpec *properties[N_PROPS];
+
+static gboolean
+ide_shortcut_accel_dialog_is_editing (IdeShortcutAccelDialog *self)
+{
+  g_assert (IDE_IS_SHORTCUT_ACCEL_DIALOG (self));
+
+  return self->editing;
+}
+
+static void
+ide_shortcut_accel_dialog_apply_state (IdeShortcutAccelDialog *self)
+{
+  g_assert (IDE_IS_SHORTCUT_ACCEL_DIALOG (self));
+
+  if (self->editing)
+    {
+      gtk_stack_set_visible_child_name (self->stack, "selection");
+      gtk_dialog_set_response_sensitive (GTK_DIALOG (self), GTK_RESPONSE_ACCEPT, FALSE);
+    }
+  else
+    {
+      gtk_stack_set_visible_child_name (self->stack, "display");
+      gtk_dialog_set_response_sensitive (GTK_DIALOG (self), GTK_RESPONSE_ACCEPT, TRUE);
+    }
+}
+
+static GdkModifierType
+sanitize_modifier_mask (GdkModifierType mods)
+{
+  mods &= gtk_accelerator_get_default_mod_mask ();
+  mods &= ~GDK_LOCK_MASK;
+
+  return mods;
+}
+
+static gboolean
+ide_shortcut_accel_dialog_key_pressed (GtkWidget             *widget,
+                                       guint                  keyval,
+                                       guint                  keycode,
+                                       GdkModifierType        state,
+                                       GtkEventControllerKey *controller)
+{
+  IdeShortcutAccelDialog *self = (IdeShortcutAccelDialog *)widget;
+
+  g_assert (IDE_IS_SHORTCUT_ACCEL_DIALOG (self));
+  g_assert (GTK_IS_EVENT_CONTROLLER_KEY (controller));
+
+  if (ide_shortcut_accel_dialog_is_editing (self))
+    {
+      GdkEvent *key = gtk_event_controller_get_current_event (GTK_EVENT_CONTROLLER (controller));
+      GdkModifierType real_mask;
+      guint keyval_lower;
+
+      if (gdk_key_event_is_modifier (key))
+        {
+          if (self->keyval == 0 && self->modifier == 0)
+            self->first_modifier = keyval;
+          return GDK_EVENT_PROPAGATE;
+        }
+
+      real_mask = state & gtk_accelerator_get_default_mod_mask ();
+      keyval_lower = gdk_keyval_to_lower (keyval);
+
+      /* Normalize <Tab> */
+      if (keyval_lower == GDK_KEY_ISO_Left_Tab)
+        keyval_lower = GDK_KEY_Tab;
+
+      /* Put shift back if it changed the case of the key */
+      if (keyval_lower != keyval)
+        real_mask |= GDK_SHIFT_MASK;
+
+      /* We don't want to use SysRq as a keybinding but we do
+       * want Alt+Print), so we avoid translation from Alt+Print to SysRq
+       */
+      if (keyval_lower == GDK_KEY_Sys_Req && (real_mask & GDK_ALT_MASK) != 0)
+        keyval_lower = GDK_KEY_Print;
+
+      /* A single Escape press cancels the editing */
+      if (!gdk_key_event_is_modifier (key) &&
+          real_mask == 0 &&
+          keyval_lower == GDK_KEY_Escape)
+        {
+          gtk_dialog_response (GTK_DIALOG (self), GTK_RESPONSE_CANCEL);
+          return GDK_EVENT_STOP;
+        }
+
+      /* Backspace disables the current shortcut */
+      if (real_mask == 0 && keyval_lower == GDK_KEY_BackSpace)
+        {
+          ide_shortcut_accel_dialog_set_accelerator (self, NULL);
+          gtk_dialog_response (GTK_DIALOG (self), GTK_RESPONSE_ACCEPT);
+          return GDK_EVENT_STOP;
+        }
+
+      self->keyval = gdk_keyval_to_lower (keyval);
+      self->modifier = sanitize_modifier_mask (state);
+
+      if ((state & GDK_SHIFT_MASK) != 0 &&
+          self->keyval == keyval)
+        self->modifier &= ~GDK_SHIFT_MASK;
+
+      if ((state & GDK_LOCK_MASK) == 0 &&
+          self->keyval != keyval)
+        self->modifier |= GDK_SHIFT_MASK;
+
+      self->editing = FALSE;
+
+      ide_shortcut_accel_dialog_apply_state (self);
+
+      g_object_notify_by_pspec (G_OBJECT (self), properties [PROP_ACCELERATOR]);
+
+      return GDK_EVENT_STOP;
+    }
+
+  return GDK_EVENT_PROPAGATE;
+}
+
+static void
+ide_shortcut_accel_dialog_key_released (GtkWidget             *widget,
+                                        guint                  keyval,
+                                        guint                  keycode,
+                                        GdkModifierType        state,
+                                        GtkEventControllerKey *controller)
+{
+  IdeShortcutAccelDialog *self = (IdeShortcutAccelDialog *)widget;
+
+  g_assert (IDE_IS_SHORTCUT_ACCEL_DIALOG (self));
+  g_assert (GTK_IS_EVENT_CONTROLLER_KEY (controller));
+
+  if (self->editing)
+    {
+      GdkEvent *key = gtk_event_controller_get_current_event (GTK_EVENT_CONTROLLER (controller));
+      /*
+       * If we have a chord defined and there was no modifier,
+       * then any key release should be enough for us to cancel
+       * our grab.
+       */
+      if (self->modifier == 0)
+        {
+          self->editing = FALSE;
+          ide_shortcut_accel_dialog_apply_state (self);
+          return;
+        }
+
+      /*
+       * If we started our sequence with a modifier, we want to
+       * release our grab when that modifier has been released.
+       */
+      if (gdk_key_event_is_modifier (key) &&
+          self->keyval != 0 &&
+          self->first_modifier != 0 &&
+          self->first_modifier == keyval)
+        {
+          self->editing = FALSE;
+          self->first_modifier = 0;
+          ide_shortcut_accel_dialog_apply_state (self);
+          return;
+        }
+    }
+}
+
+static void
+ide_shortcut_accel_dialog_constructed (GObject *object)
+{
+  IdeShortcutAccelDialog *self = (IdeShortcutAccelDialog *)object;
+
+  G_OBJECT_CLASS (ide_shortcut_accel_dialog_parent_class)->constructed (object);
+
+  gtk_dialog_set_default_response (GTK_DIALOG (self), GTK_RESPONSE_ACCEPT);
+  gtk_dialog_set_response_sensitive (GTK_DIALOG (self), GTK_RESPONSE_ACCEPT, FALSE);
+}
+
+static void
+ide_shortcut_accel_dialog_finalize (GObject *object)
+{
+  IdeShortcutAccelDialog *self = (IdeShortcutAccelDialog *)object;
+
+  g_clear_pointer (&self->shortcut_title, g_free);
+
+  G_OBJECT_CLASS (ide_shortcut_accel_dialog_parent_class)->finalize (object);
+}
+
+static void
+ide_shortcut_accel_dialog_get_property (GObject    *object,
+                                        guint       prop_id,
+                                        GValue     *value,
+                                        GParamSpec *pspec)
+{
+  IdeShortcutAccelDialog *self = IDE_SHORTCUT_ACCEL_DIALOG (object);
+
+  switch (prop_id)
+    {
+    case PROP_ACCELERATOR:
+      g_value_take_string (value, ide_shortcut_accel_dialog_get_accelerator (self));
+      break;
+
+    case PROP_SHORTCUT_TITLE:
+      g_value_set_string (value, ide_shortcut_accel_dialog_get_shortcut_title (self));
+      break;
+
+    default:
+      G_OBJECT_WARN_INVALID_PROPERTY_ID (object, prop_id, pspec);
+    }
+}
+
+static void
+ide_shortcut_accel_dialog_set_property (GObject      *object,
+                                        guint         prop_id,
+                                        const GValue *value,
+                                        GParamSpec   *pspec)
+{
+  IdeShortcutAccelDialog *self = IDE_SHORTCUT_ACCEL_DIALOG (object);
+
+  switch (prop_id)
+    {
+    case PROP_ACCELERATOR:
+      ide_shortcut_accel_dialog_set_accelerator (self, g_value_get_string (value));
+      break;
+
+    case PROP_SHORTCUT_TITLE:
+      ide_shortcut_accel_dialog_set_shortcut_title (self, g_value_get_string (value));
+      break;
+
+    default:
+      G_OBJECT_WARN_INVALID_PROPERTY_ID (object, prop_id, pspec);
+    }
+}
+
+static void
+ide_shortcut_accel_dialog_class_init (IdeShortcutAccelDialogClass *klass)
+{
+  GObjectClass *object_class = G_OBJECT_CLASS (klass);
+  GtkWidgetClass *widget_class = GTK_WIDGET_CLASS (klass);
+
+  object_class->constructed = ide_shortcut_accel_dialog_constructed;
+  object_class->finalize = ide_shortcut_accel_dialog_finalize;
+  object_class->get_property = ide_shortcut_accel_dialog_get_property;
+  object_class->set_property = ide_shortcut_accel_dialog_set_property;
+
+  properties [PROP_ACCELERATOR] =
+    g_param_spec_string ("accelerator",
+                         "Accelerator",
+                         "Accelerator",
+                         NULL,
+                         (G_PARAM_READWRITE | G_PARAM_EXPLICIT_NOTIFY | G_PARAM_STATIC_STRINGS));
+
+  properties [PROP_SHORTCUT_TITLE] =
+    g_param_spec_string ("shortcut-title",
+                         "Title",
+                         "Title",
+                         NULL,
+                         (G_PARAM_READWRITE | G_PARAM_EXPLICIT_NOTIFY | G_PARAM_STATIC_STRINGS));
+
+  g_object_class_install_properties (object_class, N_PROPS, properties);
+
+  gtk_widget_class_set_template_from_resource (widget_class, 
"/org/gnome/libide-gtk/ide-shortcut-accel-dialog.ui");
+
+  gtk_widget_class_bind_template_child (widget_class, IdeShortcutAccelDialog, stack);
+  gtk_widget_class_bind_template_child (widget_class, IdeShortcutAccelDialog, selection_label);
+  gtk_widget_class_bind_template_child (widget_class, IdeShortcutAccelDialog, display_label);
+  gtk_widget_class_bind_template_child (widget_class, IdeShortcutAccelDialog, display_shortcut);
+  gtk_widget_class_bind_template_callback (widget_class, ide_shortcut_accel_dialog_key_pressed);
+  gtk_widget_class_bind_template_callback (widget_class, ide_shortcut_accel_dialog_key_released);
+}
+
+static void
+ide_shortcut_accel_dialog_init (IdeShortcutAccelDialog *self)
+{
+  self->editing = TRUE;
+
+  gtk_widget_init_template (GTK_WIDGET (self));
+
+  g_object_bind_property (self, "accelerator",
+                          self->display_shortcut, "accelerator",
+                          G_BINDING_SYNC_CREATE);
+
+#ifdef DEVELOPMENT_BUILD
+  gtk_widget_add_css_class (GTK_WIDGET (self), "devel");
+#endif
+}
+
+gchar *
+ide_shortcut_accel_dialog_get_accelerator (IdeShortcutAccelDialog *self)
+{
+  g_return_val_if_fail (IDE_IS_SHORTCUT_ACCEL_DIALOG (self), NULL);
+
+  if (self->keyval == 0)
+    return NULL;
+
+  return gtk_accelerator_name (self->keyval, self->modifier);
+}
+
+void
+ide_shortcut_accel_dialog_set_accelerator (IdeShortcutAccelDialog *self,
+                                           const gchar            *accelerator)
+{
+  guint keyval;
+  GdkModifierType state;
+
+  g_return_if_fail (IDE_IS_SHORTCUT_ACCEL_DIALOG (self));
+
+  if (accelerator == NULL)
+    {
+      if (self->keyval != 0 || self->modifier != 0)
+        {
+          self->keyval = 0;
+          self->modifier = 0;
+          g_object_notify_by_pspec (G_OBJECT (self), properties [PROP_ACCELERATOR]);
+        }
+    }
+  else if (gtk_accelerator_parse (accelerator, &keyval, &state))
+    {
+      if (keyval != self->keyval || state != self->modifier)
+        {
+          self->keyval = keyval;
+          self->modifier = state;
+          g_object_notify_by_pspec (G_OBJECT (self), properties [PROP_ACCELERATOR]);
+        }
+    }
+}
+
+void
+ide_shortcut_accel_dialog_set_shortcut_title (IdeShortcutAccelDialog *self,
+                                              const gchar            *shortcut_title)
+{
+  g_return_if_fail (IDE_IS_SHORTCUT_ACCEL_DIALOG (self));
+
+  if (g_strcmp0 (shortcut_title, self->shortcut_title) != 0)
+    {
+      g_autofree gchar *label = NULL;
+
+      if (shortcut_title != NULL)
+        {
+          /* Translators: <b>%s</b> is used to show the provided text in bold */
+          label = g_strdup_printf (_("Enter new shortcut to change <b>%s</b>."), shortcut_title);
+        }
+
+      gtk_label_set_label (self->selection_label, label);
+      gtk_label_set_label (self->display_label, label);
+
+      g_free (self->shortcut_title);
+      self->shortcut_title = g_strdup (shortcut_title);
+
+      g_object_notify_by_pspec (G_OBJECT (self), properties [PROP_SHORTCUT_TITLE]);
+    }
+}
+
+const gchar *
+ide_shortcut_accel_dialog_get_shortcut_title (IdeShortcutAccelDialog *self)
+{
+  g_return_val_if_fail (IDE_IS_SHORTCUT_ACCEL_DIALOG (self), NULL);
+
+  return self->shortcut_title;
+}
+
+GtkWidget *
+ide_shortcut_accel_dialog_new (void)
+{
+  return g_object_new (IDE_TYPE_SHORTCUT_ACCEL_DIALOG, NULL);
+}
diff --git a/src/libide/gtk/ide-shortcut-accel-dialog.h b/src/libide/gtk/ide-shortcut-accel-dialog.h
new file mode 100644
index 000000000..2503eb95e
--- /dev/null
+++ b/src/libide/gtk/ide-shortcut-accel-dialog.h
@@ -0,0 +1,51 @@
+/* ide-shortcut-accel-dialog.h
+ *
+ * Copyright 2017-2022 Christian Hergert <chergert redhat com>
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+#pragma once
+
+#if !defined (IDE_GTK_INSIDE) && !defined (IDE_GTK_COMPILATION)
+# error "Only <libide-gtk.h> can be included directly."
+#endif
+
+#include <gtk/gtk.h>
+
+#include <libide-core.h>
+
+G_BEGIN_DECLS
+
+#define IDE_TYPE_SHORTCUT_ACCEL_DIALOG (ide_shortcut_accel_dialog_get_type())
+
+IDE_AVAILABLE_IN_ALL
+G_DECLARE_FINAL_TYPE (IdeShortcutAccelDialog, ide_shortcut_accel_dialog, IDE, SHORTCUT_ACCEL_DIALOG, 
GtkDialog)
+
+IDE_AVAILABLE_IN_ALL
+GtkWidget              *ide_shortcut_accel_dialog_new                (void);
+IDE_AVAILABLE_IN_ALL
+char                   *ide_shortcut_accel_dialog_get_accelerator    (IdeShortcutAccelDialog *self);
+IDE_AVAILABLE_IN_ALL
+void                    ide_shortcut_accel_dialog_set_accelerator    (IdeShortcutAccelDialog *self,
+                                                                      const char             *accelerator);
+IDE_AVAILABLE_IN_ALL
+const char             *ide_shortcut_accel_dialog_get_shortcut_title (IdeShortcutAccelDialog *self);
+IDE_AVAILABLE_IN_ALL
+void                    ide_shortcut_accel_dialog_set_shortcut_title (IdeShortcutAccelDialog *self,
+                                                                      const char             *title);
+
+G_END_DECLS
diff --git a/src/libide/gtk/ide-shortcut-accel-dialog.ui b/src/libide/gtk/ide-shortcut-accel-dialog.ui
new file mode 100644
index 000000000..1fe3a3d0d
--- /dev/null
+++ b/src/libide/gtk/ide-shortcut-accel-dialog.ui
@@ -0,0 +1,118 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<interface>
+  <requires lib="gtk" version="4.0"/>
+  <template class="IdeShortcutAccelDialog" parent="GtkDialog">
+    <property name="resizable">false</property>
+    <property name="modal">true</property>
+    <property name="width-request">400</property>
+    <property name="height-request">300</property>
+    <child>
+      <object class="GtkEventControllerKey">
+        <property name="propagation-phase">capture</property>
+        <signal name="key-pressed" handler="ide_shortcut_accel_dialog_key_pressed" swapped="true" 
object="IdeShortcutAccelDialog"/>
+        <signal name="key-released" handler="ide_shortcut_accel_dialog_key_released" swapped="true" 
object="IdeShortcutAccelDialog"/>
+      </object>
+    </child>
+    <child type="titlebar">
+      <object class="GtkHeaderBar">
+        <property name="show-title-buttons">false</property>
+        <child type="start">
+          <object class="GtkButton" id="cancel_button">
+            <property name="label" translatable="yes">_Cancel</property>
+            <property name="use-underline">true</property>
+          </object>
+        </child>
+        <child type="end">
+          <object class="GtkButton" id="accept_button">
+            <property name="label" translatable="yes">_Set</property>
+            <property name="use-underline">true</property>
+          </object>
+        </child>
+      </object>
+    </child>
+    <child internal-child="content_area">
+      <object class="GtkBox">
+        <property name="orientation">vertical</property>
+        <property name="vexpand">true</property>
+        <child>
+          <object class="GtkStack" id="stack">
+            <property name="hhomogeneous">true</property>
+            <property name="vhomogeneous">true</property>
+            <property name="margin-top">24</property>
+            <property name="margin-bottom">24</property>
+            <property name="margin-start">24</property>
+            <property name="margin-end">24</property>
+            <child>
+              <object class="GtkStackPage">
+                <property name="name">selection</property>
+                <property name="child">
+                  <object class="GtkBox">
+                    <property name="orientation">vertical</property>
+                    <property name="spacing">18</property>
+                    <child>
+                      <object class="GtkLabel" id="selection_label">
+                        <property name="xalign">0.5</property>
+                        <property name="use-markup">true</property>
+                      </object>
+                    </child>
+                    <child>
+                      <object class="GtkImage">
+                        <property 
name="resource">/org/gnome/libide-gtk/icons/enter-keyboard-shortcut.svg</property>
+                        <property name="hexpand">true</property>
+                        <property name="vexpand">true</property>
+                      </object>
+                    </child>
+                    <child>
+                      <object class="GtkLabel">
+                        <property name="xalign">0.5</property>
+                        <property name="label" translatable="yes">Press Escape to cancel or Backspace to 
disable.</property>
+                        <style>
+                          <class name="dim-label"/>
+                        </style>
+                      </object>
+                    </child>
+                  </object>
+                </property>
+              </object>
+            </child>
+            <child>
+              <object class="GtkStackPage">
+                <property name="name">display</property>
+                <property name="child">
+                  <object class="GtkBox">
+                    <property name="orientation">vertical</property>
+                    <property name="spacing">18</property>
+                    <child>
+                      <object class="GtkLabel" id="display_label">
+                        <property name="xalign">0.5</property>
+                        <property name="use-markup">true</property>
+                      </object>
+                    </child>
+                    <child>
+                      <object class="GtkShortcutLabel" id="display_shortcut">
+                        <property name="halign">center</property>
+                        <style>
+                          <class name="dim-label"/>
+                        </style>
+                      </object>
+                    </child>
+                  </object>
+                </property>
+              </object>
+            </child>
+          </object>
+        </child>
+      </object>
+    </child>
+    <action-widgets>
+      <action-widget response="cancel">cancel_button</action-widget>
+      <action-widget response="accept">accept_button</action-widget>
+    </action-widgets>
+  </template>
+  <object class="GtkSizeGroup">
+    <widgets>
+      <widget name="cancel_button"/>
+      <widget name="accept_button"/>
+    </widgets>
+  </object>
+</interface>
diff --git a/src/libide/gtk/ide-tree-expander.c b/src/libide/gtk/ide-tree-expander.c
new file mode 100644
index 000000000..1765dcabf
--- /dev/null
+++ b/src/libide/gtk/ide-tree-expander.c
@@ -0,0 +1,598 @@
+/* ide-tree-expander.c
+ *
+ * Copyright 2022 Christian Hergert <chergert redhat com>
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+#define G_LOG_DOMAIN "ide-tree-expander"
+
+#include "config.h"
+
+#include "ide-tree-expander.h"
+
+struct _IdeTreeExpander
+{
+  GtkWidget       parent_instance;
+
+  GtkWidget      *image;
+  GtkWidget      *title;
+  GtkWidget      *suffix;
+
+  GMenuModel     *menu_model;
+
+  GtkTreeListRow *list_row;
+
+  const char     *icon_name;
+  const char     *expanded_icon_name;
+
+  gulong          list_row_notify_depth;
+  gulong          list_row_notify_expanded;
+};
+
+enum {
+  PROP_0,
+  PROP_EXPANDED_ICON_NAME,
+  PROP_ICON_NAME,
+  PROP_ITEM,
+  PROP_LIST_ROW,
+  PROP_MENU_MODEL,
+  PROP_SUFFIX,
+  PROP_TITLE,
+  N_PROPS
+};
+
+G_DEFINE_FINAL_TYPE (IdeTreeExpander, ide_tree_expander, GTK_TYPE_WIDGET)
+
+static GParamSpec *properties [N_PROPS];
+
+static void
+ide_tree_expander_update_depth (IdeTreeExpander *self)
+{
+  static GType builtin_icon_type = G_TYPE_INVALID;
+  guint depth;
+
+  g_assert (IDE_IS_TREE_EXPANDER (self));
+
+  if (self->list_row != NULL)
+    depth = gtk_tree_list_row_get_depth (self->list_row);
+  else
+    depth = 0;
+
+  for (;;)
+    {
+      GtkWidget *child = gtk_widget_get_first_child (GTK_WIDGET (self));
+
+      if (child == self->image)
+        break;
+
+      gtk_widget_unparent (child);
+    }
+
+  if (builtin_icon_type == G_TYPE_INVALID)
+    builtin_icon_type = g_type_from_name ("GtkBuiltinIcon");
+
+  for (guint i = 0; i < depth; i++)
+    {
+      GtkWidget *child;
+
+      child = g_object_new (builtin_icon_type,
+                            "css-name", "indent",
+                            "accessible-role", GTK_ACCESSIBLE_ROLE_PRESENTATION,
+                            NULL);
+      gtk_widget_insert_after (child, GTK_WIDGET (self), NULL);
+    }
+
+  /* The level property is >= 1 */
+  gtk_accessible_update_property (GTK_ACCESSIBLE (self),
+                                  GTK_ACCESSIBLE_PROPERTY_LEVEL, depth + 1,
+                                  -1);
+}
+
+static void
+ide_tree_expander_update_icon (IdeTreeExpander *self)
+{
+  const char *icon_name;
+
+  g_assert (IDE_IS_TREE_EXPANDER (self));
+
+  if (self->list_row != NULL && gtk_tree_list_row_get_expanded (self->list_row))
+    icon_name = self->expanded_icon_name ? self->expanded_icon_name : self->icon_name;
+  else
+    icon_name = self->icon_name;
+
+  gtk_image_set_from_icon_name (GTK_IMAGE (self->image), icon_name);
+}
+
+static void
+ide_tree_expander_notify_depth_cb (IdeTreeExpander *self,
+                                   GParamSpec      *pspec,
+                                   GtkTreeListRow  *list_row)
+{
+  g_assert (IDE_IS_TREE_EXPANDER (self));
+  g_assert (GTK_IS_TREE_LIST_ROW (list_row));
+
+  ide_tree_expander_update_depth (self);
+}
+
+static void
+ide_tree_expander_notify_expanded_cb (IdeTreeExpander *self,
+                                      GParamSpec      *pspec,
+                                      GtkTreeListRow  *list_row)
+{
+  g_assert (IDE_IS_TREE_EXPANDER (self));
+  g_assert (GTK_IS_TREE_LIST_ROW (list_row));
+
+  ide_tree_expander_update_icon (self);
+}
+
+static void
+ide_tree_expander_click_pressed_cb (IdeTreeExpander *self,
+                                    int              n_press,
+                                    double           x,
+                                    double           y,
+                                    GtkGestureClick *click)
+{
+  g_assert (IDE_IS_TREE_EXPANDER (self));
+  g_assert (GTK_IS_GESTURE_CLICK (click));
+
+  if (n_press != 1 ||
+      self->list_row == NULL ||
+      !gtk_tree_list_row_is_expandable (self->list_row))
+    return;
+
+  gtk_widget_activate_action (GTK_WIDGET (self), "listitem.toggle-expand", NULL);
+
+  gtk_widget_set_state_flags (GTK_WIDGET (self), GTK_STATE_FLAG_ACTIVE, FALSE);
+  gtk_gesture_set_state (GTK_GESTURE (click), GTK_EVENT_SEQUENCE_CLAIMED);
+}
+
+static void
+ide_tree_expander_click_released_cb (IdeTreeExpander *self,
+                                     int              n_press,
+                                     double           x,
+                                     double           y,
+                                     GtkGestureClick *click)
+{
+  g_assert (IDE_IS_TREE_EXPANDER (self));
+  g_assert (GTK_IS_GESTURE_CLICK (click));
+
+  if (n_press != 1 ||
+      self->list_row == NULL ||
+      !gtk_tree_list_row_is_expandable (self->list_row))
+    return;
+
+  gtk_widget_unset_state_flags (GTK_WIDGET (self), GTK_STATE_FLAG_ACTIVE);
+  gtk_gesture_set_state (GTK_GESTURE (click), GTK_EVENT_SEQUENCE_CLAIMED);
+}
+
+static void
+ide_tree_expander_click_cancel_cb (IdeTreeExpander  *self,
+                                   GdkEventSequence *sequence,
+                                   GtkGestureClick  *click)
+{
+  g_assert (IDE_IS_TREE_EXPANDER (self));
+  g_assert (GTK_IS_GESTURE_CLICK (click));
+
+  gtk_widget_unset_state_flags (GTK_WIDGET (self), GTK_STATE_FLAG_ACTIVE);
+  gtk_gesture_set_state (GTK_GESTURE (click), GTK_EVENT_SEQUENCE_CLAIMED);
+}
+
+static void
+ide_tree_expander_toggle_expand (GtkWidget  *widget,
+                                 const char *action_name,
+                                 GVariant   *parameter)
+{
+  IdeTreeExpander *self = (IdeTreeExpander *)widget;
+
+  g_assert (IDE_IS_TREE_EXPANDER (self));
+
+  if (self->list_row == NULL)
+    return;
+
+  gtk_tree_list_row_set_expanded (self->list_row,
+                                  !gtk_tree_list_row_get_expanded (self->list_row));
+}
+
+static void
+ide_tree_expander_dispose (GObject *object)
+{
+  IdeTreeExpander *self = (IdeTreeExpander *)object;
+
+  ide_tree_expander_set_list_row (self, NULL);
+
+  g_clear_pointer (&self->image, gtk_widget_unparent);
+  g_clear_pointer (&self->title, gtk_widget_unparent);
+  g_clear_pointer (&self->suffix, gtk_widget_unparent);
+
+  g_clear_object (&self->list_row);
+  g_clear_object (&self->menu_model);
+
+  G_OBJECT_CLASS (ide_tree_expander_parent_class)->dispose (object);
+}
+
+static void
+ide_tree_expander_get_property (GObject    *object,
+                                guint       prop_id,
+                                GValue     *value,
+                                GParamSpec *pspec)
+{
+  IdeTreeExpander *self = IDE_TREE_EXPANDER (object);
+
+  switch (prop_id)
+    {
+    case PROP_EXPANDED_ICON_NAME:
+      g_value_set_string (value, ide_tree_expander_get_expanded_icon_name (self));
+      break;
+
+    case PROP_ICON_NAME:
+      g_value_set_string (value, ide_tree_expander_get_icon_name (self));
+      break;
+
+    case PROP_ITEM:
+      g_value_take_object (value, ide_tree_expander_get_item (self));
+      break;
+
+    case PROP_LIST_ROW:
+      g_value_set_object (value, ide_tree_expander_get_list_row (self));
+      break;
+
+    case PROP_MENU_MODEL:
+      g_value_set_object (value, ide_tree_expander_get_menu_model (self));
+      break;
+
+    case PROP_SUFFIX:
+      g_value_set_object (value, ide_tree_expander_get_suffix (self));
+      break;
+
+    case PROP_TITLE:
+      g_value_set_string (value, ide_tree_expander_get_title (self));
+      break;
+
+    default:
+      G_OBJECT_WARN_INVALID_PROPERTY_ID (object, prop_id, pspec);
+    }
+}
+
+static void
+ide_tree_expander_set_property (GObject      *object,
+                                guint         prop_id,
+                                const GValue *value,
+                                GParamSpec   *pspec)
+{
+  IdeTreeExpander *self = IDE_TREE_EXPANDER (object);
+
+  switch (prop_id)
+    {
+    case PROP_EXPANDED_ICON_NAME:
+      ide_tree_expander_set_expanded_icon_name (self, g_value_get_string (value));
+      break;
+
+    case PROP_ICON_NAME:
+      ide_tree_expander_set_icon_name (self, g_value_get_string (value));
+      break;
+
+    case PROP_LIST_ROW:
+      ide_tree_expander_set_list_row (self, g_value_get_object (value));
+      break;
+
+    case PROP_MENU_MODEL:
+      ide_tree_expander_set_menu_model (self, g_value_get_object (value));
+      break;
+
+    case PROP_SUFFIX:
+      ide_tree_expander_set_suffix (self, g_value_get_object (value));
+      break;
+
+    case PROP_TITLE:
+      ide_tree_expander_set_title (self, g_value_get_string (value));
+      break;
+
+    default:
+      G_OBJECT_WARN_INVALID_PROPERTY_ID (object, prop_id, pspec);
+    }
+}
+
+static void
+ide_tree_expander_class_init (IdeTreeExpanderClass *klass)
+{
+  GObjectClass *object_class = G_OBJECT_CLASS (klass);
+  GtkWidgetClass *widget_class = GTK_WIDGET_CLASS (klass);
+
+  object_class->dispose = ide_tree_expander_dispose;
+  object_class->get_property = ide_tree_expander_get_property;
+  object_class->set_property = ide_tree_expander_set_property;
+
+  properties[PROP_EXPANDED_ICON_NAME] =
+    g_param_spec_string ("expanded-icon-name", NULL, NULL,
+                         NULL,
+                         (G_PARAM_READWRITE | G_PARAM_EXPLICIT_NOTIFY | G_PARAM_STATIC_STRINGS));
+
+  properties[PROP_ICON_NAME] =
+    g_param_spec_string ("icon-name", NULL, NULL,
+                         NULL,
+                         (G_PARAM_READWRITE | G_PARAM_EXPLICIT_NOTIFY | G_PARAM_STATIC_STRINGS));
+
+  properties[PROP_ITEM] =
+    g_param_spec_object ("item", NULL, NULL,
+                         G_TYPE_OBJECT,
+                         (G_PARAM_READABLE | G_PARAM_EXPLICIT_NOTIFY | G_PARAM_STATIC_STRINGS));
+
+  properties[PROP_LIST_ROW] =
+    g_param_spec_object ("list-row", NULL, NULL,
+                         GTK_TYPE_TREE_LIST_ROW,
+                         (G_PARAM_READWRITE | G_PARAM_EXPLICIT_NOTIFY | G_PARAM_STATIC_STRINGS));
+
+  properties[PROP_MENU_MODEL] =
+    g_param_spec_object ("menu-model", NULL, NULL,
+                         G_TYPE_MENU_MODEL,
+                         (G_PARAM_READWRITE | G_PARAM_EXPLICIT_NOTIFY | G_PARAM_STATIC_STRINGS));
+
+  properties[PROP_SUFFIX] =
+    g_param_spec_object ("suffix", NULL, NULL,
+                         GTK_TYPE_WIDGET,
+                         (G_PARAM_READWRITE | G_PARAM_EXPLICIT_NOTIFY | G_PARAM_STATIC_STRINGS));
+
+  properties [PROP_TITLE] =
+    g_param_spec_string ("title", NULL, NULL,
+                         NULL,
+                         (G_PARAM_READWRITE | G_PARAM_EXPLICIT_NOTIFY | G_PARAM_STATIC_STRINGS));
+
+  g_object_class_install_properties (object_class, N_PROPS, properties);
+
+  gtk_widget_class_set_layout_manager_type (widget_class, GTK_TYPE_BOX_LAYOUT);
+  gtk_widget_class_set_css_name (widget_class, "treeexpander");
+  gtk_widget_class_set_accessible_role (widget_class, GTK_ACCESSIBLE_ROLE_GROUP);
+
+  gtk_widget_class_install_action (widget_class, "listitem.toggle-expand", NULL, 
ide_tree_expander_toggle_expand);
+}
+
+static void
+ide_tree_expander_init (IdeTreeExpander *self)
+{
+  GtkEventController *controller;
+
+  self->image = g_object_new (GTK_TYPE_IMAGE, NULL);
+  gtk_widget_insert_after (self->image, GTK_WIDGET (self), NULL);
+
+  self->title = g_object_new (GTK_TYPE_LABEL,
+                              "halign", GTK_ALIGN_START,
+                              "ellipsize", PANGO_ELLIPSIZE_END,
+                              "margin-start", 3,
+                              "margin-end", 3,
+                              NULL);
+  gtk_widget_insert_after (self->title, GTK_WIDGET (self), self->image);
+
+  controller = GTK_EVENT_CONTROLLER (gtk_gesture_click_new ());
+  g_signal_connect_object (controller,
+                           "pressed",
+                           G_CALLBACK (ide_tree_expander_click_pressed_cb),
+                           self,
+                           G_CONNECT_SWAPPED);
+  g_signal_connect_object (controller,
+                           "released",
+                           G_CALLBACK (ide_tree_expander_click_released_cb),
+                           self,
+                           G_CONNECT_SWAPPED);
+  g_signal_connect_object (controller,
+                           "cancel",
+                           G_CALLBACK (ide_tree_expander_click_cancel_cb),
+                           self,
+                           G_CONNECT_SWAPPED);
+  gtk_widget_add_controller (GTK_WIDGET (self), controller);
+}
+
+/**
+ * ide_tree_expander_get_item:
+ * @self: a #IdeTreeExpander
+ *
+ * Gets the item instance from the model.
+ *
+ * Returns: (transfer full) (nullable) (type GObject): a #GObject or %NULL
+ */
+gpointer
+ide_tree_expander_get_item (IdeTreeExpander *self)
+{
+  g_return_val_if_fail (IDE_IS_TREE_EXPANDER (self), NULL);
+
+  if (self->list_row == NULL)
+    return NULL;
+
+  return gtk_tree_list_row_get_item (self->list_row);
+}
+
+/**
+ * ide_tree_expander_get_menu_model:
+ * @self: a #IdeTreeExpander
+ *
+ * Sets the menu model to use for context menus.
+ *
+ * Returns: (transfer none) (nullable): a #GMenuModel or %NULL
+ */
+GMenuModel *
+ide_tree_expander_get_menu_model (IdeTreeExpander *self)
+{
+  g_return_val_if_fail (IDE_IS_TREE_EXPANDER (self), NULL);
+
+  return self->menu_model;
+}
+
+void
+ide_tree_expander_set_menu_model (IdeTreeExpander *self,
+                                  GMenuModel      *menu_model)
+{
+  g_return_if_fail (IDE_IS_TREE_EXPANDER (self));
+  g_return_if_fail (!menu_model || G_IS_MENU_MODEL (menu_model));
+
+  if (g_set_object (&self->menu_model, menu_model))
+    g_object_notify_by_pspec (G_OBJECT (self), properties[PROP_MENU_MODEL]);
+}
+
+const char *
+ide_tree_expander_get_expanded_icon_name (IdeTreeExpander *self)
+{
+  g_return_val_if_fail (IDE_IS_TREE_EXPANDER (self), NULL);
+
+  return self->expanded_icon_name;
+}
+
+void
+ide_tree_expander_set_expanded_icon_name (IdeTreeExpander *self,
+                                          const char      *expanded_icon_name)
+{
+  g_return_if_fail (IDE_IS_TREE_EXPANDER (self));
+
+  if (!ide_str_equal0 (self->expanded_icon_name, expanded_icon_name))
+    {
+      self->expanded_icon_name = g_intern_string (expanded_icon_name);
+      ide_tree_expander_update_icon (self);
+      g_object_notify_by_pspec (G_OBJECT (self), properties[PROP_EXPANDED_ICON_NAME]);
+    }
+}
+
+const char *
+ide_tree_expander_get_icon_name (IdeTreeExpander *self)
+{
+  g_return_val_if_fail (IDE_IS_TREE_EXPANDER (self), NULL);
+
+  return self->icon_name;
+}
+
+void
+ide_tree_expander_set_icon_name (IdeTreeExpander *self,
+                                 const char      *icon_name)
+{
+  g_return_if_fail (IDE_IS_TREE_EXPANDER (self));
+
+  if (!ide_str_equal0 (self->icon_name, icon_name))
+    {
+      self->icon_name = g_intern_string (icon_name);
+      ide_tree_expander_update_icon (self);
+      g_object_notify_by_pspec (G_OBJECT (self), properties[PROP_ICON_NAME]);
+    }
+}
+
+/**
+ * ide_tree_expander_get_suffix:
+ * @self: a #IdeTreeExpander
+ *
+ * Get the suffix widget, if any.
+ *
+ * Returns: (transfer none) (nullable): a #GtkWidget
+ */
+GtkWidget *
+ide_tree_expander_get_suffix (IdeTreeExpander *self)
+{
+  g_return_val_if_fail (IDE_IS_TREE_EXPANDER (self), NULL);
+
+  return self->suffix;
+}
+
+void
+ide_tree_expander_set_suffix (IdeTreeExpander *self,
+                              GtkWidget       *suffix)
+{
+  g_return_if_fail (IDE_IS_TREE_EXPANDER (self));
+  g_return_if_fail (!suffix || GTK_IS_WIDGET (suffix));
+
+  if (self->suffix == suffix)
+    return;
+
+  g_clear_pointer (&self->suffix, gtk_widget_unparent);
+
+  self->suffix = suffix;
+
+  if (self->suffix)
+    gtk_widget_insert_before (suffix, GTK_WIDGET (self), NULL);
+
+  g_object_notify_by_pspec (G_OBJECT (self), properties[PROP_SUFFIX]);
+}
+
+const char *
+ide_tree_expander_get_title (IdeTreeExpander *self)
+{
+  g_return_val_if_fail (IDE_IS_TREE_EXPANDER (self), NULL);
+
+  return gtk_label_get_label (GTK_LABEL (self->title));
+}
+
+void
+ide_tree_expander_set_title (IdeTreeExpander *self,
+                             const char      *title)
+{
+  g_return_if_fail (IDE_IS_TREE_EXPANDER (self));
+
+  if (!ide_str_equal0 (title, ide_tree_expander_get_title (self)))
+    {
+      gtk_label_set_label (GTK_LABEL (self->title), title);
+      g_object_notify_by_pspec (G_OBJECT (self), properties [PROP_TITLE]);
+    }
+}
+
+/**
+ * ide_tree_expander_get_list_row:
+ * @self: a #IdeTreeExpander
+ *
+ * Gets the list row for the expander.
+ *
+ * Returns: (transfer none) (nullable): a #GtkTreeListRow or %NULL
+ */
+GtkTreeListRow *
+ide_tree_expander_get_list_row (IdeTreeExpander *self)
+{
+  g_return_val_if_fail (IDE_IS_TREE_EXPANDER (self), NULL);
+
+  return self->list_row;
+}
+
+void
+ide_tree_expander_set_list_row (IdeTreeExpander *self,
+                                GtkTreeListRow  *list_row)
+{
+  g_return_if_fail (IDE_IS_TREE_EXPANDER (self));
+  g_return_if_fail (!list_row || GTK_IS_TREE_LIST_ROW (list_row));
+
+  if (self->list_row == list_row)
+    return;
+
+  if (self->list_row != NULL)
+    {
+      g_clear_signal_handler (&self->list_row_notify_depth, self->list_row);
+      g_clear_signal_handler (&self->list_row_notify_expanded, self->list_row);
+    }
+
+  g_set_object (&self->list_row, list_row);
+
+  if (self->list_row != NULL)
+    {
+      self->list_row_notify_expanded = g_signal_connect_object (self->list_row,
+                                                                "notify::expanded",
+                                                                G_CALLBACK 
(ide_tree_expander_notify_expanded_cb),
+                                                                self,
+                                                                G_CONNECT_SWAPPED);
+      self->list_row_notify_depth = g_signal_connect_object (self->list_row,
+                                                             "notify::depth",
+                                                             G_CALLBACK (ide_tree_expander_notify_depth_cb),
+                                                             self,
+                                                             G_CONNECT_SWAPPED);
+    }
+
+  ide_tree_expander_update_depth (self);
+  ide_tree_expander_update_icon (self);
+
+  g_object_notify_by_pspec (G_OBJECT (self), properties[PROP_LIST_ROW]);
+  g_object_notify_by_pspec (G_OBJECT (self), properties[PROP_ITEM]);
+}
diff --git a/src/libide/gtk/ide-tree-expander.h b/src/libide/gtk/ide-tree-expander.h
new file mode 100644
index 000000000..8481a97c3
--- /dev/null
+++ b/src/libide/gtk/ide-tree-expander.h
@@ -0,0 +1,69 @@
+/* ide-tree-expander.h
+ *
+ * Copyright 2022 Christian Hergert <chergert redhat com>
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+#pragma once
+
+#include <gtk/gtk.h>
+
+#include <libide-core.h>
+
+G_BEGIN_DECLS
+
+#define IDE_TYPE_TREE_EXPANDER (ide_tree_expander_get_type())
+
+IDE_AVAILABLE_IN_ALL
+G_DECLARE_FINAL_TYPE (IdeTreeExpander, ide_tree_expander, IDE, TREE_EXPANDER, GtkWidget)
+
+IDE_AVAILABLE_IN_ALL
+GtkWidget      *ide_tree_expander_new                    (void);
+IDE_AVAILABLE_IN_ALL
+GMenuModel     *ide_tree_expander_get_menu_model         (IdeTreeExpander *self);
+IDE_AVAILABLE_IN_ALL
+void            ide_tree_expander_set_menu_model         (IdeTreeExpander *self,
+                                                          GMenuModel      *menu_model);
+IDE_AVAILABLE_IN_ALL
+const char     *ide_tree_expander_get_icon_name          (IdeTreeExpander *self);
+IDE_AVAILABLE_IN_ALL
+void            ide_tree_expander_set_icon_name          (IdeTreeExpander *self,
+                                                          const char      *icon_name);
+IDE_AVAILABLE_IN_ALL
+const char     *ide_tree_expander_get_expanded_icon_name (IdeTreeExpander *self);
+IDE_AVAILABLE_IN_ALL
+void            ide_tree_expander_set_expanded_icon_name (IdeTreeExpander *self,
+                                                          const char      *expanded_icon_name);
+IDE_AVAILABLE_IN_ALL
+const char     *ide_tree_expander_get_title              (IdeTreeExpander *self);
+IDE_AVAILABLE_IN_ALL
+void            ide_tree_expander_set_title              (IdeTreeExpander *self,
+                                                          const char      *title);
+IDE_AVAILABLE_IN_ALL
+GtkWidget      *ide_tree_expander_get_suffix             (IdeTreeExpander *self);
+IDE_AVAILABLE_IN_ALL
+void            ide_tree_expander_set_suffix             (IdeTreeExpander *self,
+                                                          GtkWidget       *suffix);
+IDE_AVAILABLE_IN_ALL
+GtkTreeListRow *ide_tree_expander_get_list_row           (IdeTreeExpander *self);
+IDE_AVAILABLE_IN_ALL
+void            ide_tree_expander_set_list_row           (IdeTreeExpander *self,
+                                                          GtkTreeListRow  *list_row);
+IDE_AVAILABLE_IN_ALL
+gpointer        ide_tree_expander_get_item               (IdeTreeExpander *self);
+
+G_END_DECLS
diff --git a/src/libide/gtk/ide-truncate-model.c b/src/libide/gtk/ide-truncate-model.c
new file mode 100644
index 000000000..979ba2755
--- /dev/null
+++ b/src/libide/gtk/ide-truncate-model.c
@@ -0,0 +1,358 @@
+/* ide-truncate-model.c
+ *
+ * Copyright 2019 Christian Hergert <chergert redhat com>
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+#define G_LOG_DOMAIN "ide-truncate-model"
+
+#include "config.h"
+
+#include "ide-truncate-model.h"
+
+#define DEFAULT_MAX_ITEMS 4
+
+struct _IdeTruncateModel
+{
+  GObject     parent_instance;
+  GListModel *child_model;
+  guint       max_items;
+  guint       prev_n_items;
+  guint       expanded : 1;
+};
+
+static gpointer
+ide_truncate_model_get_item (GListModel *model,
+                             guint       position)
+{
+  return g_list_model_get_item (IDE_TRUNCATE_MODEL (model)->child_model, position);
+}
+
+static guint
+ide_truncate_model_get_n_items (GListModel *model)
+{
+  IdeTruncateModel *self = (IdeTruncateModel *)model;
+  guint n_items = g_list_model_get_n_items (IDE_TRUNCATE_MODEL (model)->child_model);
+  return self->expanded ? n_items : MIN (n_items, self->max_items);
+}
+
+static GType
+ide_truncate_model_get_item_type (GListModel *model)
+{
+  return g_list_model_get_item_type (IDE_TRUNCATE_MODEL (model)->child_model);
+}
+
+static void
+list_model_iface_init (GListModelInterface *iface)
+{
+  iface->get_item = ide_truncate_model_get_item;
+  iface->get_n_items = ide_truncate_model_get_n_items;
+  iface->get_item_type = ide_truncate_model_get_item_type;
+}
+
+G_DEFINE_FINAL_TYPE_WITH_CODE (IdeTruncateModel, ide_truncate_model, G_TYPE_OBJECT,
+                         G_IMPLEMENT_INTERFACE (G_TYPE_LIST_MODEL, list_model_iface_init))
+
+enum {
+  PROP_0,
+  PROP_CAN_EXPAND,
+  PROP_CHILD_MODEL,
+  PROP_EXPANDED,
+  PROP_MAX_ITEMS,
+  N_PROPS
+};
+
+static GParamSpec *properties [N_PROPS];
+
+/**
+ * ide_truncate_model_new:
+ * @child_model: a #GListModel
+ *
+ * Create a new #IdeTruncateModel that wraps @child_model. Only
+ * #IdeTruncateModel:max-items will be displayed until
+ * #IdeTrunicateModel:expanded is set.
+ *
+ * Returns: (transfer full): a newly created #IdeTruncateModel
+ */
+IdeTruncateModel *
+ide_truncate_model_new (GListModel *child_model)
+{
+  g_return_val_if_fail (G_IS_LIST_MODEL (child_model), NULL);
+
+  return g_object_new (IDE_TYPE_TRUNCATE_MODEL,
+                       "child-model", child_model,
+                       NULL);
+}
+
+static void
+ide_truncate_model_items_changed_cb (IdeTruncateModel *self,
+                                     guint             position,
+                                     guint             removed,
+                                     guint             added,
+                                     GListModel       *model)
+{
+  guint n_items;
+
+  g_assert (IDE_IS_TRUNCATE_MODEL (self));
+  g_assert (G_IS_LIST_MODEL (model));
+
+  n_items = g_list_model_get_n_items (model);
+
+  if (self->expanded)
+    {
+      g_list_model_items_changed (G_LIST_MODEL (self), position, removed, added);
+    }
+  else
+    {
+      if (position < (self->max_items - 1))
+        {
+          g_list_model_items_changed (G_LIST_MODEL (self),
+                                      0,
+                                      self->prev_n_items,
+                                      MIN (n_items, self->max_items));
+        }
+    }
+
+  self->prev_n_items = n_items;
+
+  g_object_notify_by_pspec (G_OBJECT (self), properties [PROP_CAN_EXPAND]);
+}
+
+static void
+ide_truncate_model_finalize (GObject *object)
+{
+  IdeTruncateModel *self = (IdeTruncateModel *)object;
+
+  g_clear_object (&self->child_model);
+
+  G_OBJECT_CLASS (ide_truncate_model_parent_class)->finalize (object);
+}
+
+static void
+ide_truncate_model_get_property (GObject    *object,
+                                 guint       prop_id,
+                                 GValue     *value,
+                                 GParamSpec *pspec)
+{
+  IdeTruncateModel *self = IDE_TRUNCATE_MODEL (object);
+
+  switch (prop_id)
+    {
+    case PROP_CAN_EXPAND:
+      g_value_set_boolean (value, ide_truncate_model_get_can_expand (self));
+      break;
+
+    case PROP_CHILD_MODEL:
+      g_value_set_object (value, ide_truncate_model_get_child_model (self));
+      break;
+
+    case PROP_MAX_ITEMS:
+      g_value_set_uint (value, ide_truncate_model_get_max_items (self));
+      break;
+
+    case PROP_EXPANDED:
+      g_value_set_boolean (value, ide_truncate_model_get_expanded (self));
+      break;
+
+    default:
+      G_OBJECT_WARN_INVALID_PROPERTY_ID (object, prop_id, pspec);
+    }
+}
+
+static void
+ide_truncate_model_set_property (GObject      *object,
+                                 guint         prop_id,
+                                 const GValue *value,
+                                 GParamSpec   *pspec)
+{
+  IdeTruncateModel *self = IDE_TRUNCATE_MODEL (object);
+
+  switch (prop_id)
+    {
+    case PROP_CHILD_MODEL:
+      self->child_model = g_value_dup_object (value);
+      self->prev_n_items = g_list_model_get_n_items (self->child_model);
+      g_signal_connect_object (self->child_model,
+                               "items-changed",
+                               G_CALLBACK (ide_truncate_model_items_changed_cb),
+                               self,
+                               G_CONNECT_SWAPPED);
+      break;
+
+    case PROP_MAX_ITEMS:
+      ide_truncate_model_set_max_items (self, g_value_get_uint (value));
+      break;
+
+    case PROP_EXPANDED:
+      ide_truncate_model_set_expanded (self, g_value_get_boolean (value));
+      break;
+
+    default:
+      G_OBJECT_WARN_INVALID_PROPERTY_ID (object, prop_id, pspec);
+    }
+}
+
+static void
+ide_truncate_model_class_init (IdeTruncateModelClass *klass)
+{
+  GObjectClass *object_class = G_OBJECT_CLASS (klass);
+
+  object_class->finalize = ide_truncate_model_finalize;
+  object_class->get_property = ide_truncate_model_get_property;
+  object_class->set_property = ide_truncate_model_set_property;
+
+  properties [PROP_CAN_EXPAND] =
+    g_param_spec_boolean ("can-expand",
+                          "Can Expand",
+                          "If the model can be expanded",
+                          FALSE,
+                          (G_PARAM_READABLE | G_PARAM_STATIC_STRINGS));
+
+  /**
+   * IdeTruncateModel:child-model:
+   *
+   * The "child-model" property is the model to be trunicated.
+   */
+  properties [PROP_CHILD_MODEL] =
+    g_param_spec_object ("child-model",
+                         "Child Model",
+                         "Child GListModel",
+                         G_TYPE_LIST_MODEL,
+                         (G_PARAM_READWRITE | G_PARAM_CONSTRUCT_ONLY | G_PARAM_STATIC_STRINGS));
+
+  properties [PROP_MAX_ITEMS] =
+    g_param_spec_uint ("max-items",
+                       "Max Items",
+                       "Max items to display when not expanded",
+                       0, G_MAXUINT, DEFAULT_MAX_ITEMS,
+                       (G_PARAM_READWRITE | G_PARAM_EXPLICIT_NOTIFY | G_PARAM_STATIC_STRINGS));
+
+  properties [PROP_EXPANDED] =
+    g_param_spec_boolean ("expanded",
+                          "Expanded",
+                          "If all the items should be displayed",
+                          FALSE,
+                          (G_PARAM_READWRITE | G_PARAM_EXPLICIT_NOTIFY | G_PARAM_STATIC_STRINGS));
+
+  g_object_class_install_properties (object_class, N_PROPS, properties);
+}
+
+static void
+ide_truncate_model_init (IdeTruncateModel *self)
+{
+  self->max_items = DEFAULT_MAX_ITEMS;
+}
+
+gboolean
+ide_truncate_model_get_expanded (IdeTruncateModel *self)
+{
+  g_return_val_if_fail (IDE_IS_TRUNCATE_MODEL (self), FALSE);
+
+  return self->expanded;
+}
+
+void
+ide_truncate_model_set_expanded (IdeTruncateModel *self,
+                                 gboolean          expanded)
+{
+  g_return_if_fail (IDE_IS_TRUNCATE_MODEL (self));
+
+  expanded = !!expanded;
+
+  if (expanded != self->expanded)
+    {
+      guint n_items = g_list_model_get_n_items (self->child_model);
+      guint old_n_items = self->expanded ? n_items : MIN (n_items, self->max_items);
+      guint new_n_items = expanded ? n_items : MIN (n_items, self->max_items);
+
+      self->expanded = expanded;
+
+      if (new_n_items > old_n_items)
+        g_list_model_items_changed (G_LIST_MODEL (self),
+                                    old_n_items,
+                                    0,
+                                    new_n_items - old_n_items);
+      else
+        g_list_model_items_changed (G_LIST_MODEL (self),
+                                    0, old_n_items, new_n_items);
+
+      g_object_notify_by_pspec (G_OBJECT (self), properties [PROP_EXPANDED]);
+      g_object_notify_by_pspec (G_OBJECT (self), properties [PROP_CAN_EXPAND]);
+    }
+}
+
+guint
+ide_truncate_model_get_max_items (IdeTruncateModel *self)
+{
+  g_return_val_if_fail (IDE_IS_TRUNCATE_MODEL (self), 0);
+
+  return self->max_items;
+}
+
+void
+ide_truncate_model_set_max_items (IdeTruncateModel *self,
+                                  guint             max_items)
+{
+  g_return_if_fail (IDE_IS_TRUNCATE_MODEL (self));
+
+  if (max_items == 0)
+    max_items = DEFAULT_MAX_ITEMS;
+
+  if (max_items != self->max_items)
+    {
+      guint old_max_items = self->max_items;
+
+      self->max_items = max_items;
+
+      if (!self->expanded)
+        {
+          guint n_items = g_list_model_get_n_items (self->child_model);
+          guint old_n_items = MIN (old_max_items, n_items);
+          guint new_n_items = MIN (max_items, n_items);
+
+          if (old_n_items != new_n_items)
+            g_list_model_items_changed (G_LIST_MODEL (self), 0, old_n_items, new_n_items);
+        }
+
+      g_object_notify_by_pspec (G_OBJECT (self), properties [PROP_MAX_ITEMS]);
+      g_object_notify_by_pspec (G_OBJECT (self), properties [PROP_CAN_EXPAND]);
+    }
+}
+
+/**
+ * ide_truncate_model_get_child_model:
+ *
+ * Gets the #IdeTruncateModel:child-model property.
+ *
+ * Returns: (transfer none): a #GListModel
+ */
+GListModel *
+ide_truncate_model_get_child_model (IdeTruncateModel *self)
+{
+  g_return_val_if_fail (IDE_IS_TRUNCATE_MODEL (self), NULL);
+
+  return self->child_model;
+}
+
+gboolean
+ide_truncate_model_get_can_expand (IdeTruncateModel *self)
+{
+  g_return_val_if_fail (IDE_IS_TRUNCATE_MODEL (self), FALSE);
+
+  return !self->expanded &&
+         g_list_model_get_n_items (self->child_model) > self->max_items;
+}
diff --git a/src/libide/gtk/ide-truncate-model.h b/src/libide/gtk/ide-truncate-model.h
new file mode 100644
index 000000000..8be92a0a2
--- /dev/null
+++ b/src/libide/gtk/ide-truncate-model.h
@@ -0,0 +1,53 @@
+/* ide-truncate-model.h
+ *
+ * Copyright 2019 Christian Hergert <chergert redhat com>
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+#pragma once
+
+#if !defined (IDE_GTK_INSIDE) && !defined (IDE_GTK_COMPILATION)
+# error "Only <libide-gtk.h> can be included directly."
+#endif
+
+#include <libide-core.h>
+
+G_BEGIN_DECLS
+
+#define IDE_TYPE_TRUNCATE_MODEL (ide_truncate_model_get_type())
+
+IDE_AVAILABLE_IN_ALL
+G_DECLARE_FINAL_TYPE (IdeTruncateModel, ide_truncate_model, IDE, TRUNCATE_MODEL, GObject)
+
+IDE_AVAILABLE_IN_ALL
+IdeTruncateModel *ide_truncate_model_new             (GListModel       *child_model);
+IDE_AVAILABLE_IN_ALL
+GListModel       *ide_truncate_model_get_child_model (IdeTruncateModel *self);
+IDE_AVAILABLE_IN_ALL
+guint             ide_truncate_model_get_max_items   (IdeTruncateModel *self);
+IDE_AVAILABLE_IN_ALL
+void              ide_truncate_model_set_max_items   (IdeTruncateModel *self,
+                                                      guint             max_items);
+IDE_AVAILABLE_IN_ALL
+gboolean          ide_truncate_model_get_can_expand  (IdeTruncateModel *self);
+IDE_AVAILABLE_IN_ALL
+gboolean          ide_truncate_model_get_expanded    (IdeTruncateModel *self);
+IDE_AVAILABLE_IN_ALL
+void              ide_truncate_model_set_expanded    (IdeTruncateModel *self,
+                                                      gboolean          expanded);
+
+G_END_DECLS
diff --git a/src/libide/gtk/libide-gtk.gresource.xml b/src/libide/gtk/libide-gtk.gresource.xml
new file mode 100644
index 000000000..167fa8a18
--- /dev/null
+++ b/src/libide/gtk/libide-gtk.gresource.xml
@@ -0,0 +1,9 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<gresources>
+  <gresource prefix="/org/gnome/libide-gtk">
+    <file preprocess="xml-stripblanks">ide-entry-popover.ui</file>
+    <file preprocess="xml-stripblanks">ide-search-entry.ui</file>
+    <file preprocess="xml-stripblanks">ide-shortcut-accel-dialog.ui</file>
+    <file preprocess="xml-stripblanks">icons/enter-keyboard-shortcut.svg</file>
+  </gresource>
+</gresources>
diff --git a/src/libide/gtk/libide-gtk.h b/src/libide/gtk/libide-gtk.h
new file mode 100644
index 000000000..18929e199
--- /dev/null
+++ b/src/libide/gtk/libide-gtk.h
@@ -0,0 +1,42 @@
+/* libide-gtk.h
+ *
+ * Copyright 2022 Christian Hergert <chergert redhat com>
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+#pragma once
+
+#define IDE_GTK_INSIDE
+# include "ide-animation.h"
+# include "ide-cell-renderer-fancy.h"
+# include "ide-entry-popover.h"
+# include "ide-enum-object.h"
+# include "ide-fancy-tree-view.h"
+# include "ide-file-chooser-entry.h"
+# include "ide-file-manager.h"
+# include "ide-font-description.h"
+# include "ide-gtk.h"
+# include "ide-gtk-enums.h"
+# include "ide-joined-menu.h"
+# include "ide-menu-manager.h"
+# include "ide-progress-icon.h"
+# include "ide-radio-box.h"
+# include "ide-search-entry.h"
+# include "ide-shortcut-accel-dialog.h"
+# include "ide-tree-expander.h"
+# include "ide-truncate-model.h"
+#undef IDE_GTK_INSIDE
diff --git a/src/libide/gtk/meson.build b/src/libide/gtk/meson.build
new file mode 100644
index 000000000..908c983a8
--- /dev/null
+++ b/src/libide/gtk/meson.build
@@ -0,0 +1,127 @@
+libide_gtk_header_dir = join_paths(libide_header_dir, 'gtk')
+libide_gtk_header_subdir = join_paths(libide_header_subdir, 'gtk')
+libide_include_directories += include_directories('.')
+
+#
+# Public API Headers
+#
+
+libide_gtk_public_headers = [
+  'ide-animation.h',
+  'ide-cell-renderer-fancy.h',
+  'ide-entry-popover.h',
+  'ide-enum-object.h',
+  'ide-fancy-tree-view.h',
+  'ide-file-chooser-entry.h',
+  'ide-file-manager.h',
+  'ide-font-description.h',
+  'ide-gtk.h',
+  'ide-joined-menu.h',
+  'ide-menu-manager.h',
+  'ide-progress-icon.h',
+  'ide-radio-box.h',
+  'ide-search-entry.h',
+  'ide-shortcut-accel-dialog.h',
+  'ide-tree-expander.h',
+  'ide-truncate-model.h',
+  'libide-gtk.h',
+]
+
+libide_gtk_enum_headers = [
+  'ide-animation.h',
+]
+
+install_headers(libide_gtk_public_headers, subdir: libide_gtk_header_subdir)
+
+#
+# Sources
+#
+
+libide_gtk_public_sources = [
+  'ide-animation.c',
+  'ide-cell-renderer-fancy.c',
+  'ide-entry-popover.c',
+  'ide-enum-object.c',
+  'ide-fancy-tree-view.c',
+  'ide-file-chooser-entry.c',
+  'ide-file-manager.c',
+  'ide-font-description.c',
+  'ide-gtk.c',
+  'ide-joined-menu.c',
+  'ide-menu-manager.c',
+  'ide-progress-icon.c',
+  'ide-radio-box.c',
+  'ide-search-entry.c',
+  'ide-shortcut-accel-dialog.c',
+  'ide-tree-expander.c',
+  'ide-truncate-model.c',
+]
+
+libide_gtk_private_sources = [
+  'ide-frame-source.c',
+  'ide-gtk-init.c',
+]
+
+#
+# Enum generation
+#
+
+libide_gtk_enums = gnome.mkenums_simple('ide-gtk-enums',
+     body_prefix: '#include "config.h"',
+   header_prefix: '#include <libide-core.h>',
+       decorator: '_IDE_EXTERN',
+         sources: libide_gtk_enum_headers,
+  install_header: true,
+     install_dir: libide_gtk_header_dir,
+)
+libide_gtk_generated_sources = [libide_gtk_enums[0]]
+libide_gtk_generated_headers = [libide_gtk_enums[1]]
+
+#
+# Generated Resource Files
+#
+
+libide_gtk_resources = gnome.compile_resources(
+  'ide-gtk-resources',
+  'libide-gtk.gresource.xml',
+  c_name: 'ide_gtk',
+)
+libide_gtk_generated_sources += [libide_gtk_resources[0]]
+libide_gtk_generated_headers += [libide_gtk_resources[1]]
+
+#
+# Dependencies
+#
+
+libide_gtk_deps = [
+  libgio_dep,
+  libgtk_dep,
+
+  libide_core_dep,
+  libide_io_dep,
+  libide_threading_dep,
+]
+
+#
+# Library Definitions
+#
+
+libide_gtk = static_library('ide-gtk-' + libide_api_version,
+   libide_gtk_public_sources + libide_gtk_private_sources + libide_gtk_generated_sources + 
libide_gtk_generated_headers,
+   dependencies: libide_gtk_deps,
+         c_args: libide_args + release_args + ['-DIDE_GTK_COMPILATION'],
+)
+
+libide_gtk_dep = declare_dependency(
+         dependencies: libide_gtk_deps,
+            link_with: libide_gtk,
+  include_directories: include_directories('.'),
+             sources: libide_gtk_generated_headers,
+)
+
+gnome_builder_public_sources += files(libide_gtk_public_sources)
+gnome_builder_public_headers += files(libide_gtk_public_headers)
+gnome_builder_generated_headers += libide_gtk_generated_headers
+gnome_builder_generated_sources += libide_gtk_generated_sources
+gnome_builder_include_subdirs += libide_gtk_header_subdir
+gnome_builder_gir_extra_args += ['--c-include=libide-gtk.h', '-DIDE_GTK_COMPILATION']
diff --git a/src/libide/gui/libide-gui.h b/src/libide/gui/libide-gui.h
index 47baf66ea..8177535da 100644
--- a/src/libide/gui/libide-gui.h
+++ b/src/libide/gui/libide-gui.h
@@ -41,7 +41,6 @@
 #include "ide-frame-empty-state.h"
 #include "ide-frame-header.h"
 #include "ide-header-bar.h"
-#include "ide-fancy-tree-view.h"
 #include "ide-grid.h"
 #include "ide-grid-column.h"
 #include "ide-gui-global.h"
diff --git a/src/libide/gui/meson.build b/src/libide/gui/meson.build
index 585ba7d30..53d188e04 100644
--- a/src/libide/gui/meson.build
+++ b/src/libide/gui/meson.build
@@ -10,13 +10,11 @@ libide_gui_generated_headers = []
 libide_gui_public_headers = [
   'ide-application.h',
   'ide-application-addin.h',
-  'ide-cell-renderer-fancy.h',
   'ide-command.h',
   'ide-command-manager.h',
   'ide-command-provider.h',
   'ide-config-view-addin.h',
   'ide-environment-editor.h',
-  'ide-fancy-tree-view.h',
   'ide-frame-addin.h',
   'ide-frame-empty-state.h',
   'ide-frame-header.h',
@@ -112,13 +110,11 @@ libide_gui_public_sources = [
   'ide-application-addin.c',
   'ide-application-command-line.c',
   'ide-application-open.c',
-  'ide-cell-renderer-fancy.c',
   'ide-command.c',
   'ide-command-manager.c',
   'ide-command-provider.c',
   'ide-config-view-addin.c',
   'ide-environment-editor.c',
-  'ide-fancy-tree-view.c',
   'ide-frame-addin.c',
   'ide-frame-empty-state.c',
   'ide-frame-header.c',
diff --git a/src/libide/meson.build b/src/libide/meson.build
index d03bb17a1..55df2abd5 100644
--- a/src/libide/meson.build
+++ b/src/libide/meson.build
@@ -6,6 +6,7 @@ subdir('core')
 subdir('plugins')
 subdir('threading')
 subdir('io')
+subdir('gtk')
 subdir('code')
 subdir('vcs')
 subdir('projects')


[Date Prev][Date Next]   [Thread Prev][Thread Next]   [Thread Index] [Date Index] [Author Index]