diff --git a/Tools/OutWit.Database.Studio.Tests/AHandlerRunsOnlyOnARouteItsEventTravelsTests.cs b/Tools/OutWit.Database.Studio.Tests/AHandlerRunsOnlyOnARouteItsEventTravelsTests.cs
new file mode 100644
index 0000000..7aa0bd7
--- /dev/null
+++ b/Tools/OutWit.Database.Studio.Tests/AHandlerRunsOnlyOnARouteItsEventTravelsTests.cs
@@ -0,0 +1,209 @@
+using System.Text.RegularExpressions;
+using Avalonia.Controls;
+using Avalonia.Input;
+using Avalonia.Interactivity;
+
+namespace OutWit.Database.Studio.Tests;
+
+///
+/// A handler asked for a route its event does not travel is never called, and nothing says so.
+///
+///
+///
+/// Measured on 2026-08-19, and it had shipped. The double click that opens a table's data
+/// (WS-19) stopped working once a table had a placeholder child to expand, because the tree toggles
+/// the row on a double tap and marks the event handled. The repair was to register the handler as
+/// TUNNELLING, so that it would run first - and DoubleTapped is registered as
+/// Bubble ALONE. A tunnelling handler for it is not an early handler, it is no handler: the
+/// double click did nothing at all from that commit until this one, and 1014 tests, a green CI and a
+/// signed release had nothing to say about it.
+///
+///
+/// AddHandler takes the route as a plain argument and cannot refuse an impossible one, so this
+/// is checked here: every AddHandler in Studio is read out of the source, the event is
+/// resolved to the real , and the route asked for must be one the event
+/// actually travels.
+///
+///
+[TestFixture]
+public class AHandlerRunsOnlyOnARouteItsEventTravelsTests
+{
+ #region Constants
+
+ ///
+ /// AddHandler(SomeEvent, Handler, ...), whether or not it names a route. A call that
+ /// leaves the route out gets Bubble, which every routed event travels; those are counted anyway,
+ /// because a walk that reports no offenders has to be able to say what it read.
+ ///
+ private static readonly Regex ADD_HANDLER = new(
+ @"AddHandler\(\s*(?[\w\.]+)\s*,",
+ RegexOptions.Compiled);
+
+ /// The route asked for, inside the same statement.
+ private static readonly Regex ROUTES = new(
+ @"RoutingStrategies\.(?[\w\s\|\.]+?)\s*[,\)]",
+ RegexOptions.Compiled);
+
+ #endregion
+
+ #region Tests
+
+ [Test]
+ public void EveryHandlerInStudioAsksForARouteItsEventTravelsTest()
+ {
+ var events = TheRoutedEventsAvaloniaPublishes();
+
+ var offenders = new List();
+ var examined = new List();
+ var asked = 0;
+
+ foreach (var (file, source) in StudioSources())
+ foreach (Match match in ADD_HANDLER.Matches(source))
+ {
+ var name = match.Groups["event"].Value.Split('.')[^1];
+
+ Assert.That(events.ContainsKey(name), Is.True,
+ $"{file}: this fixture could not resolve {name} to a routed event");
+
+ examined.Add($"{file}: {name}");
+
+ // The overload without a route gives the handler Bubble, which every routed event
+ // travels. Only a call that NAMES one can name an impossible one.
+ var written = ROUTES.Match(Statement(source, match.Index));
+
+ if (!written.Success)
+ continue;
+
+ asked++;
+
+ var routes = Routes(written.Groups["routes"].Value);
+ var travels = events[name].RoutingStrategies;
+
+ if ((routes & travels) != routes)
+ offenders.Add($"{file}: {name} travels {travels}, and the handler asks for {routes} - "
+ + "the part that is not there is never called");
+ }
+
+ Assert.Multiple(() =>
+ {
+ // CONTROL: a walk that read no source, or a pattern that matched nothing, would report
+ // no offenders either - which is exactly what this fixture is here to disbelieve. Both
+ // halves of the reader are named: the calls it found, and the routes it read out of
+ // them.
+ Assert.That(examined, Has.Count.GreaterThanOrEqualTo(2),
+ "CONTROL: too few AddHandler calls were found - the walk or the pattern is wrong");
+
+ Assert.That(asked, Is.GreaterThanOrEqualTo(1),
+ "CONTROL: no route was read out of any of them - " + string.Join(", ", examined));
+
+ Assert.That(offenders, Is.Empty, string.Join(Environment.NewLine, offenders));
+ });
+ }
+
+ ///
+ /// The fact the case above was written for, stated on its own: DoubleTapped bubbles and
+ /// does not tunnel.
+ ///
+ ///
+ /// It is asserted rather than remembered because the repair that failed was reasoned from the
+ /// opposite assumption. If a later Avalonia gives the event a tunnelling route, this case is
+ /// where that shows up, and the rule above quietly starts allowing what it forbids today.
+ ///
+ [Test]
+ public void TheDoubleTapIsABubblingEventAndNothingElseTest()
+ {
+ Assert.Multiple(() =>
+ {
+ Assert.That(InputElement.DoubleTappedEvent.RoutingStrategies,
+ Is.EqualTo(RoutingStrategies.Bubble),
+ "a tunnelling handler for the double tap is not an early handler, it is no handler");
+
+ // The pointer is the route that DOES have a tunnel, which is where the double click
+ // belongs once the tree wants to handle it before the row does.
+ Assert.That(InputElement.PointerPressedEvent.RoutingStrategies.HasFlag(RoutingStrategies.Tunnel),
+ Is.True, "the pointer tunnels, which is why the double click is read from it");
+ });
+ }
+
+ #endregion
+
+ #region Tools
+
+ /// The statement the call is part of: from the call to the semicolon that ends it.
+ private static string Statement(string source, int index)
+ {
+ var end = source.IndexOf(';', index);
+
+ return end < 0 ? source[index..] : source[index..end];
+ }
+
+ private static RoutingStrategies Routes(string written)
+ {
+ var routes = RoutingStrategies.Direct & 0;
+
+ foreach (var part in written.Split('|', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries))
+ routes |= Enum.Parse(part.Split('.')[^1]);
+
+ return routes;
+ }
+
+ ///
+ /// Every routed event Avalonia publishes as a public static field, by name.
+ ///
+ private static IReadOnlyDictionary TheRoutedEventsAvaloniaPublishes()
+ {
+ var assemblies = new[]
+ {
+ typeof(InputElement).Assembly,
+ typeof(Control).Assembly,
+ typeof(RoutedEvent).Assembly
+ }.Distinct();
+
+ var events = new Dictionary();
+
+ foreach (var type in assemblies.SelectMany(assembly => assembly.GetExportedTypes()))
+ foreach (var field in type.GetFields(System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Static))
+ {
+ if (!typeof(RoutedEvent).IsAssignableFrom(field.FieldType))
+ continue;
+
+ if (field.GetValue(null) is RoutedEvent value)
+ events.TryAdd(field.Name, value);
+ }
+
+ return events;
+ }
+
+ private static IEnumerable<(string File, string Source)> StudioSources()
+ {
+ var root = StudioRoot();
+
+ foreach (var file in Directory.EnumerateFiles(root, "*.cs", SearchOption.AllDirectories))
+ {
+ if (file.Contains($"{Path.DirectorySeparatorChar}bin{Path.DirectorySeparatorChar}")
+ || file.Contains($"{Path.DirectorySeparatorChar}obj{Path.DirectorySeparatorChar}"))
+ continue;
+
+ yield return (Path.GetRelativePath(root, file), File.ReadAllText(file));
+ }
+ }
+
+ private static string StudioRoot()
+ {
+ var directory = new DirectoryInfo(AppContext.BaseDirectory);
+
+ while (directory != null)
+ {
+ var candidate = Path.Combine(directory.FullName, "Tools", "OutWit.Database.Studio");
+
+ if (Directory.Exists(Path.Combine(candidate, "Views")))
+ return candidate;
+
+ directory = directory.Parent;
+ }
+
+ throw new AssertionException("the Studio project was not found from " + AppContext.BaseDirectory);
+ }
+
+ #endregion
+}
diff --git a/Tools/OutWit.Database.Studio/Views/DatabaseExplorer.axaml.cs b/Tools/OutWit.Database.Studio/Views/DatabaseExplorer.axaml.cs
index 6cbd6aa..f93d3fd 100644
--- a/Tools/OutWit.Database.Studio/Views/DatabaseExplorer.axaml.cs
+++ b/Tools/OutWit.Database.Studio/Views/DatabaseExplorer.axaml.cs
@@ -25,6 +25,10 @@ public partial class DatabaseExplorer : UserControl
private string m_typed = string.Empty;
private DateTime m_typedAt = DateTime.MinValue;
+ /// The row a double click has just opened the data of, and the state it was in.
+ private TreeViewItem? m_rowToLeaveAsItWas;
+ private bool m_asItWas;
+
#endregion
#region Static
@@ -63,17 +67,30 @@ public DatabaseExplorer()
InitializeComponent();
DataContext = ApplicationViewModel.Instance;
- // TUNNELLING since 2026-08-19. A TreeViewItem toggles its own expansion on a double
- // click, and a table has had a child to expand since the placeholder arrived - so the
- // bubbling handler ran after the row had opened, and opening the DATA (WS-19) stopped
- // happening. Measured in the running application.
- AddHandler(DoubleTappedEvent, OnDoubleTapped, RoutingStrategies.Tunnel);
KeyDown += OnKeyDown;
- // Tunnelling, because a TreeViewItem handles the pointer for its own selection and a
- // bubbling handler would never see the middle button.
+ // Tunnelling, and it carries BOTH the middle click and the double click: a TreeViewItem
+ // handles the pointer for its own selection and for its own expansion, and a bubbling
+ // handler sees neither.
+ //
+ // The double click USED to be read from DoubleTapped, which is where it belongs - until a
+ // table gained a placeholder child, the row began toggling on a double tap, and the tree
+ // marked the event handled before this control saw it. That was repaired by asking for the
+ // TUNNELLING route of DoubleTapped, and there is no such route: the event is registered
+ // Bubble alone, so the handler was never called again and opening a table's data did
+ // nothing at all - through a green suite, a green CI and a signed release. The pointer is
+ // the event that tunnels, so the double click is read from it. See
+ // AHandlerRunsOnlyOnARouteItsEventTravelsTests.
AddHandler(PointerPressedEvent, OnPointerPressed, RoutingStrategies.Tunnel);
+ // And the second half of the same click. Handling the pointer press does NOT stop the
+ // double tap: the gesture is recognised from the finished route, so the row still toggled
+ // itself and a table both opened its data and opened its row. Measured on 2026-08-19. This
+ // runs after the row has done that - bubbling, and handledEventsToo because the row marks
+ // the tap handled - and puts the row back the way it was.
+ AddHandler(DoubleTappedEvent, OnDoubleTappedAfterTheRow, RoutingStrategies.Bubble,
+ handledEventsToo: true);
+
// BUBBLING, and deliberately not tunnelling: the filter box and the rename box are text
// boxes, and a tunnelling handler would eat every letter typed into them and jump the tree
// instead. A box that has taken the character marks the event handled, and a bubbling
@@ -99,26 +116,60 @@ public DatabaseExplorer()
/// application's behaviour.
///
///
- private void OnDoubleTapped(object? sender, Avalonia.Input.TappedEventArgs e)
+ private void OpenTheDataUnderThePointer(PointerPressedEventArgs e)
{
- var explorer = ApplicationViewModel.Instance.DatabaseExplorerVm;
- var node = explorer.SelectedNode;
+ // The chevron is a control of its own, and two clicks on it are two toggles rather than a
+ // request for the data.
+ if (PressedOnTheChevron(e))
+ return;
- if (node == null)
+ if (ItemUnder(e) is not { } item || item.DataContext is not DatabaseNode node)
return;
+ var explorer = ApplicationViewModel.Instance.DatabaseExplorerVm;
+
+ // The row under the pointer, not the row that happens to be selected. The first click of
+ // the pair selects it anyway; saying so here is what makes the handler independent of the
+ // order the tree does its own work in.
+ explorer.SelectedNode = node;
+
switch (node.NodeType)
{
- case Models.DatabaseNodeType.Table when explorer.CanEditData:
+ case DatabaseNodeType.Table when explorer.CanEditData:
explorer.EditDataCommand.Execute(null);
- e.Handled = true;
break;
- case Models.DatabaseNodeType.View when explorer.CanBrowseData:
+ case DatabaseNodeType.View when explorer.CanBrowseData:
explorer.SelectTop1000Command.Execute(null);
- e.Handled = true;
break;
+
+ default:
+ return;
}
+
+ // The tap that follows this press will toggle the row. Remember what to put back.
+ m_rowToLeaveAsItWas = item;
+ m_asItWas = item.IsExpanded;
+
+ e.Handled = true;
+ }
+
+ ///
+ /// A double click on a table opens its data and does nothing else - in particular it does not
+ /// open the row, which the tree does on its own and which nothing here can prevent.
+ ///
+ ///
+ /// The chevron and the arrow keys are how a row is opened, and they are unaffected: this puts
+ /// back only the row a double click has just opened the data of.
+ ///
+ private void OnDoubleTappedAfterTheRow(object? sender, TappedEventArgs e)
+ {
+ if (m_rowToLeaveAsItWas is not { } row)
+ return;
+
+ m_rowToLeaveAsItWas = null;
+
+ row.IsExpanded = m_asItWas;
}
///
@@ -132,17 +183,9 @@ private void OnDoubleTapped(object? sender, Avalonia.Input.TappedEventArgs e)
/// just opened.
///
///
- private void OnPointerPressed(object? sender, PointerPressedEventArgs e)
+ private void OpenInTheBackground(PointerPressedEventArgs e)
{
- if (!e.GetCurrentPoint(this).Properties.IsMiddleButtonPressed)
- return;
-
- var item = (e.Source as Visual)?
- .GetSelfAndVisualAncestors()
- .OfType()
- .FirstOrDefault();
-
- if (item?.DataContext is not DatabaseNode node)
+ if (NodeUnder(e) is not { } node)
return;
var explorer = ApplicationViewModel.Instance.DatabaseExplorerVm;
@@ -156,6 +199,48 @@ private void OnPointerPressed(object? sender, PointerPressedEventArgs e)
e.Handled = true;
}
+ ///
+ /// The two clicks the TREE owns, both read from the pointer and both before the row sees them.
+ ///
+ private void OnPointerPressed(object? sender, PointerPressedEventArgs e)
+ {
+ var properties = e.GetCurrentPoint(this).Properties;
+
+ if (properties.IsMiddleButtonPressed)
+ {
+ OpenInTheBackground(e);
+ return;
+ }
+
+ if (properties.IsLeftButtonPressed && e.ClickCount == 2)
+ OpenTheDataUnderThePointer(e);
+ }
+
+ /// The row the pointer is over, if it is over one.
+ private static TreeViewItem? ItemUnder(PointerPressedEventArgs e)
+ {
+ return (e.Source as Visual)?
+ .GetSelfAndVisualAncestors()
+ .OfType()
+ .FirstOrDefault();
+ }
+
+ /// The node whose row the pointer is over, if it is over one.
+ private static DatabaseNode? NodeUnder(PointerPressedEventArgs e)
+ {
+ return ItemUnder(e)?.DataContext as DatabaseNode;
+ }
+
+ /// Whether the press landed on the row's expander rather than on the row.
+ private static bool PressedOnTheChevron(PointerPressedEventArgs e)
+ {
+ return (e.Source as Visual)?
+ .GetSelfAndVisualAncestors()
+ .TakeWhile(visual => visual is not TreeViewItem)
+ .OfType