Skip to content
Shiny Controls v1.0 - The Ultra Control Suite for .NET MAUI & BlazorO...M...G!

Quick Entry

An assistant-style prompt summoned over whatever the user is looking at — PromptView in a popup — plus an optional Siri-style glow around the screen edge on the same service.

It ships in the core packages on both hosts, so it works everywhere out of the box:

  • NuGet downloads for Shiny.Maui.Controls
  • NuGet downloads for Shiny.Blazor.Controls
Frameworks
.NET MAUI
Blazor
Operating Systems
iOS
Android
Windows
macOS
Linux
Inline PromptView Popup over the page Typing a prompt Screen glow
PromptView inline on a page with its suggestion list open The quick entry popup open over a dimmed page A prompt typed into the popup The Siri-style screen glow rimming the page

On desktop the same page resolves to the Desktop presentation, which opens a borderless always-on-top OS window instead of an in-app overlay:

macOS
The quick entry page on macOS, with the presentation resolving to Desktop
Inline PromptView Popup over the page Busy, with the screen glow The answer
PromptView inline on a Blazor page with its suggestion list open The quick entry popup open over a dimmed Blazor page The popup working, with the screen glow rimming the viewport The popup showing the returned answer
Presentation What it is Where
In-app An overlay drawn over the current page Everywhere — iOS, Android, Mac Catalyst, Windows, macOS, Linux, Blazor
Desktop A borderless, always-on-top OS window that opens over other applications Windows, macOS (AppKit), Linux — with the Shiny.Maui.Controls.Desktop add-on

QuickEntryOptions.Presentation picks between them:

  • Auto (the default) — the native window where one is available, the overlay everywhere else. One setting for a shared codebase, with no platform checks at the call site.
  • InApp — always the overlay, including on desktop. Right for a popup that should stay inside your app rather than float over the whole machine.
  • Desktop — force the OS window. Where it isn’t available it falls back to the overlay and logs why, rather than failing to open.

IQuickEntryService.ResolvedPresentation tells you which you actually got.

The in-app presentation is built on the library’s own Overlay control, so it shares the page’s OverlayHost backdrop with everything else that uses one — a popup opened over a floating panel dims the page once, not twice — and inherits the blur, the close-on-backdrop-tap and the show/hide worker. A page with no host gets one installed automatically.

MAUI — registered by UseShinyControls(), so this only changes the settings:

using Shiny;
using Shiny.Maui.Controls.QuickEntry;
builder
.UseMauiApp<App>()
.UseShinyControls(cfg => cfg.ConfigureQuickEntry(o =>
{
o.Presentation = QuickEntryPresentation.Auto;
o.Placement = QuickEntryPlacement.TopCenter;
o.ScreenGlow = ScreenGlowTrigger.WhileBusy;
}));

For a real desktop window — and global hotkeys — add the desktop package and one more call:

Terminal window
dotnet add package Shiny.Maui.Controls.Desktop
builder
.UseShinyControls(cfg => cfg.ConfigureQuickEntry(o => o.HotKey = "Ctrl+Alt+Space"))
.UseDesktopQuickEntry();

Safe to call unconditionally: on Mac Catalyst and anywhere else that isn’t a desktop, the presenters report themselves unsupported and the core service quietly stays with the overlay.

Blazor — registered by AddShinyControls(), plus one host component in the root layout:

services.AddShinyControls(cfg => cfg.ConfigureQuickEntry(o =>
{
o.Placement = QuickEntryPlacement.TopCenter;
o.ScreenGlow = ScreenGlowTrigger.WhileBusy;
}));
{/* MainLayout.razor */}
<QuickEntryHost />
public class QuickEntryHost(IQuickEntryService quickEntry)
{
public async Task StartAsync()
{
// Optional: builds the native window ahead of time so the first open is instant
await quickEntry.PreloadAsync();
}
public void OnTrayIconClicked() => quickEntry.Toggle();
}
Member Notes
Show() / Hide() / Toggle() Toggle is what a hotkey, tray click or button binds to
PreloadAsync() (MAUI) Builds the popup ahead of first use — also how you reach Content before the user opens anything
Resize(width, height) (MAUI) Manual sizing. Height is clamped to MaxHeight
ResolvedPresentation Which presentation you actually got, with Auto and any fallback applied
IsOpen · Content Content is the hosted view — cast to PromptView to wire it up
ShowGlow() · HideGlow() · PulseGlowAsync() The screen glow lives on this same service
Opened / Closed Raised however the popup was dismissed

In desktop presentation the popup is a real window, so it does not grow to fit its content the way an overlay would — AutoSize (on by default) follows the content’s height for you, clamped between CollapsedHeight and MaxHeight. In-app none of that applies: the overlay is laid out by the page and MaxHeight is simply a ceiling.

Property Default Notes
HotKey null Desktop only. Accelerator that toggles the popup
Width 720 Device-independent pixels
CollapsedHeight 76 Height with just the prompt row
MaxHeight 560 Ceiling for auto-sizing
Placement TopCenter TopCenter · BottomCenter · Center · NearCursor · Manual. In-app, NearCursor reads as centred — a touch screen has no pointer. Blazor has only the first three
TopMarginRatio 0.18 Top edge as a fraction of the screen for TopCenter
BottomMarginRatio 0.12 Gap below the popup for BottomCenter, as a fraction of the working area
X / Y 0 Screen coordinates for Manual
AutoSize true Follow the content’s measured height
DismissOnFocusLost true Close when another application takes focus
DismissOnEscape true Content gets first refusal — see Keyboard
ActivateOnShow true Take keyboard focus. Turn off for a passive HUD
ShowInTaskbar false Desktop only; applied when the native window is created
JoinAllSpaces true macOS desktop presentation: appear on every Space and over full-screen apps
ContentFactory null Builds the content. Defaults to a new PromptView
RecreateContentOnShow false Off, so a half-typed prompt survives an accidental dismiss
ScreenGlow None None · WhileOpen · WhileBusy
Glow The glow’s appearance: thickness, palette, speed, intensity, blob count, layers, frame rate
ScrimColor · DismissOnScrimTap 35% black · true In-app only — the backdrop, and the touch equivalent of DismissOnFocusLost

Placement is resolved against the screen under the mouse pointer, so the popup follows the user across a multi-monitor setup rather than pinning itself to the app’s window.

The popup’s default content: an animated orb, a single-line prompt, and an area beneath it that expands for suggestions and a response.

It does no AI itself. It raises Submitted and leaves the request to you — which is what keeps it usable with any client, local or hosted.

var prompt = (PromptView)quickEntry.Content!;
prompt.Suggestions = new List<PromptSuggestion>
{
new("Summarise my clipboard", "Reads whatever you last copied", "📋"),
new("Explain this error", "Paste a stack trace", "🐞"),
new("Draft a reply", "Bullet points in, message out", "✉️")
};
prompt.Submitted += async (_, e) =>
{
prompt.ResponseContent = null;
prompt.IsBusy = true;
var answer = await chatClient.GetResponseAsync(e.Text);
prompt.IsBusy = false;
prompt.ResponseContent = new MarkdownView { Markdown = answer.Text };
};
prompt.Cancelled += (_, _) => cts.Cancel();

It is an ordinary ContentView, so it also works inline on a normal page — including on iOS and Android, where the popup itself does not exist:

<ContentPage xmlns:qe="clr-namespace:Shiny.Maui.Controls.Desktop.QuickEntry;assembly=Shiny.Maui.Controls.Desktop">
<qe:PromptView Placeholder="Ask anything…"
Text="{Binding Prompt}"
IsBusy="{Binding IsThinking}"
SubmitCommand="{Binding AskCommand}"
ShowMicrophone="True"
MicrophoneCommand="{Binding DictateCommand}" />
</ContentPage>
Property Notes
Text The prompt. Two-way by default
Placeholder Default "Ask anything…"
Icon An ImageSource in place of the built-in animated orb
IconContent Any view in the leading slot. Wins over Icon
ShowIcon · IconSize Hide the slot entirely, or resize the orb/image. Default true · 26
IsBusy / BusyText Spins the orb, shows a spinner, swaps submit for a stop button
Suggestions Honours INotifyCollectionChanged, so an ObservableCollection updated as the user types behaves like autocomplete
SuggestionTemplate Render your own rows; the binding context is the item
MaxVisibleSuggestions Default 6 — it is a HUD, not a list view
DropdownContent Any view for the expanding area under the prompt — a command palette, recent items, your own list. Renders above Suggestions, so both can be used together
DropdownHeight Unset (-1) sizes the dropdown to its content and the window follows. Set a value to pin it and scroll instead — right for a list that changes length as the user types
Response The answer as plain text, rendered in a built-in label. This is what a read-aloud tool speaks
ResponseContent Any View. Wins over Response; null collapses the area
Footer Optional bottom strip — a model picker, a keyboard legend
LeadingTools · TrailingTools IList<PromptTool>, created for you. Leading sits beside the orb, trailing before the microphone and submit glyphs
SubmitCommand · SuggestionCommand · MicrophoneCommand Command equivalents of the events
ShowMicrophone Default false — there is no speech engine in this package
ShowSubmitButton · ClearOnSubmit Both default true
AccentColor, SurfaceColor, OutlineColor, TextColor, PlaceholderColor, SubtleTextColor, HighlightColor, CornerRadius, PromptFontSize The colours default to app-theme bindings, so they follow light/dark until you assign one

Events: Submitted (carrying Text and the chosen Suggestion, or null for a typed submit), SuggestionSelected, Cancelled, ResponseChanged.

LeadingTools and TrailingTools are the prompt-bar equivalent of TextEntry’s tool slots. PromptTool shares the IconTextTool base with TextEntryTool, so Text, Icon, ToolColor, FontSize, Command / CommandParameter and Clicked behave the same way.

<qe:PromptView>
<qe:PromptView.TrailingTools>
<speech:PromptTextToSpeechTool AutoSpeak="True" />
<qe:PromptTool Text="⚙" Command="{Binding SettingsCommand}" />
</qe:PromptView.TrailingTools>
</qe:PromptView>

A tool that needs to read or drive the prompt implements IPromptAwareTool:

public class MyTool : PromptTool, IPromptAwareTool
{
PromptView? prompt;
void IPromptAwareTool.Attach(PromptView view)
{
this.prompt = view;
this.prompt.ResponseChanged += this.OnResponse;
}
void IPromptAwareTool.Detach()
{
if (this.prompt is not null)
this.prompt.ResponseChanged -= this.OnResponse;
this.prompt = null;
}
}

PromptTextToSpeechTool, in Shiny.Maui.Controls.SpeechAddins and Shiny.Blazor.Controls.SpeechAddins, speaks the answer through Shiny.Speech. It hides itself while there is nothing to read and turns into a stop button while it is speaking.

prompt.TrailingTools!.Add(new PromptTextToSpeechTool { AutoSpeak = true });
prompt.Submitted += async (_, e) =>
{
prompt.IsBusy = true;
prompt.Response = await AskAsync(e.Text); // Response, not ResponseContent
prompt.IsBusy = false;
};
Property Notes
AutoSpeak Read the answer the moment it lands, instead of waiting for a tap. Default false
HideWhenEmpty Hide the tool until there is something to read. Default true
TextSelector Func<PromptView, string?>. Needed when the answer only lives in ResponseContent
SpeechRate · Pitch · Volume · VoiceName · Culture Passed through to TextToSpeechOptions
SpeakingText · SpeakingColor The stop-state glyph and tint

Register the engine with AddSpeechServices() (or just AddTextToSpeech()). See Speech Add-ons.

MAUI has no cross-platform key-down event, so the popup host reads keys off the native window and hands them to its content.

  • ↑ / ↓ walk the suggestions, wrapping back to the prompt at either end
  • Enter submits, or picks the highlighted suggestion
  • Escape unwinds one layer of state at a time — cancel the request, drop the highlight, clear the response, clear the prompt — and only then falls through to the host, which closes the popup

Custom content takes part by implementing IQuickEntryKeyHandler:

public class MyPopupContent : ContentView, IQuickEntryKeyHandler
{
// return true to swallow the key; false lets the host act on it
public bool HandleKey(QuickEntryKey key)
=> key == QuickEntryKey.Tab && this.CycleSection();
}

Three more optional hooks: IQuickEntryPresentationAware (OnQuickEntryOpened / OnQuickEntryClosed), IQuickEntryBusyState (lets the glow’s WhileBusy trigger see your content’s working state), and IQuickEntryAutoSize (GetDesiredHeight(width) + DesiredHeightChanged). PromptView implements all of them.

Same control, same service, no OS window. <PromptView> takes ordinary parameters:

<PromptView Width="640"
@bind-Text="prompt"
Placeholder="Ask anything…"
IsBusy="busy"
Suggestions="suggestions"
Response="@answer"
Submitted="OnSubmitted" />

The popup’s own prompt is configured through the service instead, because a service cannot hand a component parameters directly — the two meet at PromptViewState:

@inject IQuickEntryService QuickEntry
@code {
protected override void OnInitialized() => QuickEntry.ConfigurePrompt(prompt =>
{
prompt.Suggestions = suggestions;
prompt.Submitted += async (_, e) =>
{
prompt.IsBusy = true;
prompt.Response = await AskAsync(e.Text);
prompt.IsBusy = false;
};
});
}

Blazor tools are plain objects rather than components, so a tool can be built in a view model and handed over as a parameter — which is also the only way the popup’s own prompt gets one:

<PromptView Response="@answer" TrailingTools="tools" />
@code {
readonly List<PromptTool> tools = new() { new PromptTextToSpeechTool() };
protected override void OnInitialized() => QuickEntry.ConfigurePrompt(prompt =>
prompt.TrailingTools.Add(new PromptTextToSpeechTool()));
}

PromptViewState.LeadingTools / TrailingTools are ObservableCollection<PromptTool>, so a tool added after the popup has been built still appears. Override OnAttached / OnDetached in place of IPromptAwareTool — both hand over Prompt and the app’s Services, so a tool can resolve what it needs without the hosting page wiring it — and call RefreshAsync() after changing the tool’s own glyph.

SetContent(RenderFragment) replaces the built-in prompt with your own markup for every open; Show(RenderFragment) does it for one.

Desktop only, and registered by UseDesktopQuickEntry() — a system-wide key grab does not exist on mobile or in a browser. Useful on its own:

var registration = hotKeys.Register("Ctrl+Shift+K", () => ToggleRecording());
if (registration == null)
{
// Could not be claimed. A normal outcome, not an exception: unsupported platform,
// unparseable string, or another application already owns the combination.
}

The callback is marshalled to the UI thread, so it is safe to touch MAUI objects directly. Dispose the registration to release the key.

Accelerators use the same grammar as tray menu accelerators — modifiers joined with +, then the key: "Ctrl+Alt+Space", "Cmd+Shift+K", "Ctrl+F12". Recognised modifiers are Ctrl/Control, Alt/Option/Opt, Shift, and Cmd/Command/Meta/Win/Super.

Platform Mechanism Notes
Windows RegisterHotKey on a message-only window Reliable. Fails if another process owns the combination
macOS (AppKit) Carbon RegisterEventHotKey No Accessibility permission prompt, unlike an NSEvent global monitor
Linux / X11 XGrabKey on the root window Grabbed with every lock-modifier combination, so Caps/Num Lock do not break it
Linux / Wayland org.freedesktop.portal.GlobalShortcuts GNOME 45+, KDE Plasma 6+
MacCatalyst Not supported

An animated colour wash around the edge of the display, click-through and always-on-top, so it never interrupts anything the user is doing.

cfg.ConfigureQuickEntry(o =>
{
o.Glow.Thickness = 130; // how far the colour reaches in from the edge
o.Glow.Palette = new List<Color> { Colors.DeepSkyBlue, Colors.MediumPurple, Colors.HotPink };
o.Glow.Speed = 0.22; // laps of the perimeter per second
o.Glow.Intensity = 0.9;
o.Glow.BlobCount = 5; // *minimum* pools; enough to rim the screen are always drawn
o.Glow.Layers = 3; // falloff passes: more is a deeper edge and costs more
o.Glow.FrameRate = 30;
});

Thickness is how far the colour reaches inward, and it is an absolute distance: 110 is a band around a desktop display and most of the width of a phone. Turn it down for a tight rim on a small screen.

BlobCount is a floor rather than the count. How many colour pools it takes to rim a screen without unlit gaps between them depends on the screen, so enough are always drawn — on a large display, well over five.

The glow is part of IQuickEntryService rather than a service of its own: the two are almost always used together, and splitting them meant an app wiring up an assistant had to resolve, configure and keep two objects in step for one visible behaviour. It works with no popup involved — for a listening, recording or long-running-job indicator of your own.

quickEntry.ShowGlow();
quickEntry.HideGlow();
await quickEntry.PulseGlowAsync(TimeSpan.FromSeconds(3)); // one-shot acknowledgement

Wire it to the popup with QuickEntryOptions.ScreenGlow:

  • None — never (you can still drive it by hand)
  • WhileOpen — the whole time the popup is up
  • WhileBusy — only while the content reports itself working. This is the closest match to Siri, which lights the edge while listening and thinking rather than the whole time it is on screen. Works with PromptView.IsBusy out of the box; custom content implements IQuickEntryBusyState

It rims the display in desktop presentation and the page in-app — the same thing on a phone, and not the same thing on a desktop with your app in a window. Availability differs from the popup, so check IsGlowSupported separately:

Platform How it is drawn
In-app, every platform An overlay layer on the current page
macOS, Linux / X11 desktop A transparent, click-through OS window
Windows desktop A WinUI 3 window has no per-pixel alpha, so the glow is rendered with GDI+ into four layered Win32 windows, one per screen edge — which is also why the Windows glow has square corners rather than following a rounded display
Blazor A CSS conic gradient masked to a band around the viewport
MacCatalyst, Linux / Wayland desktop No whole-display glow; the in-app one is used instead

The glow is a full-screen animation. FrameRate is the first thing to turn down on an older GPU, then Layers.

Under Wayland a client is not allowed to position its own toplevel, raise itself above other windows, or grab the keyboard — and GTK 4 dropped gtk_window_move and set_keep_above to match. So on a Wayland session:

  • The popup is still undecorated and transparent, but the compositor decides where it appears and it is an ordinary window in the stack
  • Hotkeys go through the desktop portal. Binding shows the user a system confirmation dialog, so the hotkey starts working asynchronously after startup — and the trigger you pass is a preference: the compositor may bind something else, and the user can rebind it. Never present the configured accelerator as fact on Wayland
  • The screen glow is unavailable

Under X11 everything behaves as it does on Windows and macOS. Where IGlobalHotKeyService.IsSupported is false, fall back to opening the popup from a tray icon.