Feature: improved symbols bar on Android, auto-capitalize keywords on both platforms

Android symbols bar:
- Rewrite using GlobalLayout + GetWindowVisibleDisplayFrame for reliable keyboard detection
- Toolbar attached once to content view, toggled via visibility (fixes crash)
- Pill-shaped container with themed colors, scrollable symbol strip
- Blue circle play icon (centered TextView), disabled when query empty
- Add symbols: { } \" ' . = * $

iOS symbols bar:
- Add same extra symbols to match Android
- Hide accessory when keyboard dismissed via external gesture

Auto-capitalize (both platforms):
- Cypher keywords auto-uppercased after cursor moves past them
- Respects auto_capitalize preference from Settings
- Skips capitalization while cursor is at end of word (no fighting while typing)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Gerwin Kuijntjes
2026-04-02 11:51:47 +02:00
parent 8eb4e3cd5e
commit 7df3395c3e
2 changed files with 247 additions and 42 deletions

View File

@@ -8,12 +8,15 @@ using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
using Android.Content; using Android.Content;
using Android.Content.Res;
using Android.Graphics; using Android.Graphics;
using Android.Graphics.Drawables;
using Android.Text; using Android.Text;
using Android.Text.Style; using Android.Text.Style;
using Android.Views; using Android.Views;
using Android.Views.InputMethods; using Android.Views.InputMethods;
using Android.Widget; using Android.Widget;
using AndroidX.Core.View;
using Microsoft.Maui.Handlers; using Microsoft.Maui.Handlers;
using Microsoft.Maui.Platform; using Microsoft.Maui.Platform;
using Xamarin.Neo4j.Controls; using Xamarin.Neo4j.Controls;
@@ -49,6 +52,9 @@ namespace Xamarin.Neo4j.Android.CustomRenderers
"FALSE", "NULL", "TRUE" "FALSE", "NULL", "TRUE"
]; ];
private LinearLayout _toolbar;
private bool _toolbarAttached;
protected override void ConnectHandler(MauiAppCompatEditText platformView) protected override void ConnectHandler(MauiAppCompatEditText platformView)
{ {
base.ConnectHandler(platformView); base.ConnectHandler(platformView);
@@ -58,43 +64,133 @@ namespace Xamarin.Neo4j.Android.CustomRenderers
| InputTypes.TextFlagMultiLine | InputTypes.TextFlagMultiLine
| InputTypes.TextFlagNoSuggestions; | InputTypes.TextFlagNoSuggestions;
// Keyboard toolbar with symbol keys and Execute button // Build the keyboard toolbar
var toolbar = BuildToolbar(platformView); _toolbar = BuildToolbar(platformView);
// Attach toolbar once to the content view (stays in hierarchy, just hidden)
AttachToolbarOnce(platformView);
// Detect keyboard via visible frame height difference.
// Edge-to-edge and varying MAUI soft-input modes make WindowInsets unreliable,
// so we compare the root view height to the visible display frame.
platformView.ViewTreeObserver.GlobalLayout += (s, e) =>
{
var rootView = platformView.RootView;
if (rootView == null) return;
var rect = new global::Android.Graphics.Rect();
rootView.GetWindowVisibleDisplayFrame(rect);
var screenHeight = rootView.Height;
var keyboardHeight = screenHeight - rect.Bottom;
if (keyboardHeight > screenHeight * 0.15 && platformView.HasFocus)
{
if (_toolbar.LayoutParameters is FrameLayout.LayoutParams lp)
{
lp.BottomMargin = keyboardHeight;
_toolbar.LayoutParameters = lp;
}
_toolbar.Visibility = ViewStates.Visible;
}
else
{
_toolbar.Visibility = ViewStates.Gone;
}
};
// Also hide on focus loss
platformView.FocusChange += (s, e) => platformView.FocusChange += (s, e) =>
toolbar.Visibility = e.HasFocus ? ViewStates.Visible : ViewStates.Gone; {
if (!e.HasFocus)
_toolbar.Visibility = ViewStates.Gone;
};
// Syntax highlighting // Syntax highlighting
platformView.AddTextChangedListener(new CypherTextWatcher(platformView, _keyWords)); platformView.AddTextChangedListener(new CypherTextWatcher(platformView, _keyWords));
HighlightSyntax(platformView, _keyWords); HighlightSyntax(platformView, _keyWords);
} }
private void AttachToolbarOnce(MauiAppCompatEditText platformView)
{
if (_toolbarAttached) return;
var activity = Microsoft.Maui.ApplicationModel.Platform.CurrentActivity;
var decorView = activity?.Window?.DecorView as ViewGroup;
var contentView = decorView?.FindViewById<FrameLayout>(global::Android.Resource.Id.Content);
if (contentView == null) return;
var layoutParams = new FrameLayout.LayoutParams(
ViewGroup.LayoutParams.MatchParent,
ViewGroup.LayoutParams.WrapContent,
GravityFlags.Bottom);
contentView.AddView(_toolbar, layoutParams);
_toolbar.Visibility = ViewStates.Gone;
_toolbarAttached = true;
}
private LinearLayout BuildToolbar(MauiAppCompatEditText platformView) private LinearLayout BuildToolbar(MauiAppCompatEditText platformView)
{ {
var context = platformView.Context; var context = platformView.Context;
var density = context.Resources.DisplayMetrics.Density;
var toolbar = new LinearLayout(context) var toolbar = new LinearLayout(context)
{ {
Orientation = Orientation.Horizontal, Orientation = global::Android.Widget.Orientation.Horizontal,
LayoutParameters = new LinearLayout.LayoutParams( LayoutParameters = new LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MatchParent, ViewGroup.LayoutParams.MatchParent,
ViewGroup.LayoutParams.WrapContent) (int)(48 * density))
}; };
toolbar.SetBackgroundColor(Color.ParseColor("#F2F2F7"));
toolbar.SetPadding(8, 8, 8, 8);
toolbar.Visibility = ViewStates.Gone;
// Scrollable symbol key strip // Theme-aware background
var isDark = (context.Resources.Configuration.UiMode & UiMode.NightMask) == UiMode.NightYes;
toolbar.SetBackgroundColor(Color.ParseColor(isDark ? "#141414" : "#F2F2F7"));
toolbar.SetPadding((int)(6 * density), (int)(6 * density), (int)(6 * density), (int)(6 * density));
toolbar.SetGravity(GravityFlags.CenterVertical);
// Pill container for symbol keys
var pillContainer = new LinearLayout(context)
{
Orientation = global::Android.Widget.Orientation.Horizontal,
LayoutParameters = new LinearLayout.LayoutParams(
0, (int)(36 * density), 1f)
};
var pillBg = new GradientDrawable();
pillBg.SetCornerRadius(18 * density);
pillBg.SetColor(Color.ParseColor(isDark ? "#2a2a2a" : "#E8E8ED").ToArgb());
pillContainer.Background = pillBg;
pillContainer.SetGravity(GravityFlags.CenterVertical);
// Scrollable symbol key strip inside pill
var scrollView = new HorizontalScrollView(context) var scrollView = new HorizontalScrollView(context)
{ {
LayoutParameters = new LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WrapContent, 1f) LayoutParameters = new LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MatchParent, ViewGroup.LayoutParams.MatchParent)
}; };
scrollView.HorizontalScrollBarEnabled = false; scrollView.HorizontalScrollBarEnabled = false;
var keyRow = new LinearLayout(context) { Orientation = Orientation.Horizontal }; var keyRow = new LinearLayout(context)
foreach (var key in new[] { "(", ")", "[", "]", ":", "-", "->", "<-" }) {
Orientation = global::Android.Widget.Orientation.Horizontal,
LayoutParameters = new LinearLayout.LayoutParams(
ViewGroup.LayoutParams.WrapContent, ViewGroup.LayoutParams.MatchParent)
};
keyRow.SetGravity(GravityFlags.CenterVertical);
var symbolTextColor = Color.ParseColor(isDark ? "#e2e2e2" : "#0c0c0c");
foreach (var key in new[] { "(", ")", "[", "]", "{", "}", ":", "-", "->", "<-", "\"", "'", ".", "=", "*", "$" })
{ {
var captured = key; var captured = key;
var btn = CreatePillButton(context, captured); var btn = new Button(context)
{
Text = captured,
LayoutParameters = new LinearLayout.LayoutParams(
(int)(40 * density), ViewGroup.LayoutParams.MatchParent)
};
btn.SetTextColor(symbolTextColor);
btn.SetBackgroundColor(Color.Transparent);
btn.SetTextSize(global::Android.Util.ComplexUnitType.Sp, 15);
btn.SetAllCaps(false);
btn.SetPadding(0, 0, 0, 0);
btn.Click += (s, e) => btn.Click += (s, e) =>
{ {
var start = Math.Max(platformView.SelectionStart, 0); var start = Math.Max(platformView.SelectionStart, 0);
@@ -105,12 +201,34 @@ namespace Xamarin.Neo4j.Android.CustomRenderers
} }
scrollView.AddView(keyRow); scrollView.AddView(keyRow);
toolbar.AddView(scrollView); pillContainer.AddView(scrollView);
toolbar.AddView(pillContainer);
// Execute button // Spacer
var executeBtn = CreatePillButton(context, "Execute"); var spacer = new global::Android.Views.View(context)
{
LayoutParameters = new LinearLayout.LayoutParams((int)(6 * density), 0)
};
toolbar.AddView(spacer);
// Execute button — blue circle with play icon (▶)
var executeBtn = new TextView(context)
{
Text = "\u25B6",
LayoutParameters = new LinearLayout.LayoutParams(
(int)(36 * density), (int)(36 * density)),
Gravity = GravityFlags.Center,
Clickable = true,
Focusable = true
};
executeBtn.SetTextColor(Color.White); executeBtn.SetTextColor(Color.White);
executeBtn.SetBackgroundColor(Color.ParseColor("#007AFF")); executeBtn.SetTextSize(global::Android.Util.ComplexUnitType.Sp, 18);
var executeBg = new GradientDrawable();
executeBg.SetCornerRadius(18 * density);
executeBg.SetColor(Color.ParseColor("#007AFF").ToArgb());
executeBtn.Background = executeBg;
executeBtn.SetPadding(0, 0, 0, 0);
executeBtn.SetIncludeFontPadding(false);
executeBtn.Click += (s, e) => executeBtn.Click += (s, e) =>
{ {
if (VirtualView is QueryEditor queryEditor) if (VirtualView is QueryEditor queryEditor)
@@ -120,33 +238,23 @@ namespace Xamarin.Neo4j.Android.CustomRenderers
imm?.HideSoftInputFromWindow(platformView.WindowToken, 0); imm?.HideSoftInputFromWindow(platformView.WindowToken, 0);
} }
}; };
// Disable run button when query is empty
void updateRunEnabled()
{
var hasText = !string.IsNullOrWhiteSpace(platformView.Text);
executeBtn.Enabled = hasText;
executeBtn.Alpha = hasText ? 1f : 0.35f;
}
updateRunEnabled();
platformView.AddTextChangedListener(new SimpleTextWatcher(updateRunEnabled));
toolbar.AddView(executeBtn); toolbar.AddView(executeBtn);
// Attach toolbar to the parent view hierarchy toolbar.Visibility = ViewStates.Gone;
platformView.ViewTreeObserver.GlobalLayout += (s, e) =>
{
if (platformView.Parent is ViewGroup parent && toolbar.Parent == null)
parent.AddView(toolbar);
};
return toolbar; return toolbar;
} }
private static Button CreatePillButton(Context context, string label)
{
return new Button(context)
{
Text = label,
LayoutParameters = new LinearLayout.LayoutParams(
ViewGroup.LayoutParams.WrapContent,
ViewGroup.LayoutParams.WrapContent)
{
MarginStart = 4,
MarginEnd = 4
}
};
}
// ── Syntax highlighting ─────────────────────────────────────────────── // ── Syntax highlighting ───────────────────────────────────────────────
internal static void HighlightSyntax(MauiAppCompatEditText platformView, IEnumerable<string> keywords) internal static void HighlightSyntax(MauiAppCompatEditText platformView, IEnumerable<string> keywords)
@@ -191,6 +299,15 @@ namespace Xamarin.Neo4j.Android.CustomRenderers
// ── Inner helpers ───────────────────────────────────────────────────── // ── Inner helpers ─────────────────────────────────────────────────────
private sealed class SimpleTextWatcher : Java.Lang.Object, ITextWatcher
{
private readonly Action _callback;
public SimpleTextWatcher(Action callback) { _callback = callback; }
public void BeforeTextChanged(Java.Lang.ICharSequence s, int start, int count, int after) { }
public void OnTextChanged(Java.Lang.ICharSequence s, int start, int before, int count) { }
public void AfterTextChanged(IEditable s) { _callback(); }
}
private sealed class CypherTextWatcher : Java.Lang.Object, ITextWatcher private sealed class CypherTextWatcher : Java.Lang.Object, ITextWatcher
{ {
private readonly MauiAppCompatEditText _view; private readonly MauiAppCompatEditText _view;
@@ -210,9 +327,50 @@ namespace Xamarin.Neo4j.Android.CustomRenderers
{ {
if (_updating) return; if (_updating) return;
_updating = true; _updating = true;
try { HighlightSyntax(_view, _keywords); } try
{
AutoCapitalizeKeywords(_view, _keywords);
HighlightSyntax(_view, _keywords);
}
finally { _updating = false; } finally { _updating = false; }
} }
} }
internal static void AutoCapitalizeKeywords(MauiAppCompatEditText platformView, IEnumerable<string> keywords)
{
if (!Microsoft.Maui.Storage.Preferences.Default.Get("auto_capitalize", true)) return;
var text = platformView.Text ?? string.Empty;
var selStart = platformView.SelectionStart;
var selEnd = platformView.SelectionEnd;
var changed = false;
var chars = text.ToCharArray();
foreach (var word in keywords)
{
var regex = new Regex("\\b" + Regex.Escape(word) + "\\b", RegexOptions.IgnoreCase);
foreach (Match match in regex.Matches(text))
{
// Skip if already uppercase
var segment = text.Substring(match.Index, match.Length);
if (segment == word) continue;
// Don't capitalize if cursor is right at the end of this word (user still typing)
if (selStart == match.Index + match.Length) continue;
for (var i = 0; i < match.Length; i++)
chars[match.Index + i] = word[i];
changed = true;
}
}
if (changed)
{
var newText = new string(chars);
platformView.SetText(newText, TextView.BufferType.Editable);
if (selStart >= 0 && selStart <= newText.Length)
platformView.SetSelection(Math.Min(selStart, newText.Length), Math.Min(selEnd, newText.Length));
}
}
} }
} }

View File

@@ -67,7 +67,9 @@ namespace Xamarin.Neo4j.iOS.CustomRenderers
var keys = new[] var keys = new[]
{ {
("(", "("), (")", ")"), ("[", "["), ("]", "]"), ("(", "("), (")", ")"), ("[", "["), ("]", "]"),
(":", ":"), ("-", "-"), ("\u2192", "->"), ("\u2190", "<-") ("{", "{"), ("}", "}"), (":", ":"), ("-", "-"),
("\u2192", "->"), ("\u2190", "<-"), ("\"", "\""),
("'", "'"), (".", "."), ("=", "="), ("*", "*"), ("$", "$")
}; };
// Outer accessory — clear so system background shows through // Outer accessory — clear so system background shows through
@@ -147,7 +149,21 @@ namespace Xamarin.Neo4j.iOS.CustomRenderers
platformView.InputAccessoryView = accessoryView; platformView.InputAccessoryView = accessoryView;
platformView.Changed += (s, e) => HighlightWords(platformView, _keyWords); // Ensure accessory hides when keyboard is dismissed by external gesture/scroll
var keyboardHideObserver = UIKeyboard.Notifications.ObserveWillHide((s, args) =>
{
// The InputAccessoryView is removed with the keyboard automatically.
// But if the editor remains first responder after an external dismiss
// (e.g. interactive dismiss on scroll), resign to fully hide the bar.
if (platformView.IsFirstResponder)
platformView.ResignFirstResponder();
});
platformView.Changed += (s, e) =>
{
AutoCapitalizeKeywords(platformView, _keyWords);
HighlightWords(platformView, _keyWords);
};
HighlightWords(platformView, _keyWords); HighlightWords(platformView, _keyWords);
} }
@@ -192,6 +208,37 @@ namespace Xamarin.Neo4j.iOS.CustomRenderers
} }
} }
private static void AutoCapitalizeKeywords(UITextView platformView, IEnumerable<string> keywords)
{
if (!Microsoft.Maui.Storage.Preferences.Default.Get("auto_capitalize", true)) return;
var text = platformView.Text ?? string.Empty;
var cursorPos = platformView.SelectedRange;
var changed = false;
var chars = text.ToCharArray();
foreach (var word in keywords)
{
var regex = new Regex("\\b" + Regex.Escape(word) + "\\b", RegexOptions.IgnoreCase);
foreach (Match match in regex.Matches(text))
{
var segment = text.Substring(match.Index, match.Length);
if (segment == word) continue;
if ((nint)cursorPos.Location == match.Index + match.Length) continue;
for (var i = 0; i < match.Length; i++)
chars[match.Index + i] = word[i];
changed = true;
}
}
if (changed)
{
platformView.Text = new string(chars);
platformView.SelectedRange = cursorPos;
}
}
private static string PreprocessText(string text) private static string PreprocessText(string text)
{ {
text = text.Replace("\u2018", "'"); text = text.Replace("\u2018", "'");