diff --git a/ui-actions.cc b/ui-actions.cc
index 751d723..b6ae5da 100644
--- a/ui-actions.cc
+++ b/ui-actions.cc
@@ -28,6 +28,14 @@ void T_KeyboardShortcut::toString(
 			}( ) );
 }
 
+namespace ebcl {
+M_DEFINE_HASH( T_KeyboardShortcut )
+{
+	return uint32_t( item.character ) << 8
+		| uint32_t( item.modifiers );
+}
+} // namespace ebcl
+
 
 /*= T_UIAction ===============================================================*/
 
diff --git a/ui-actions.hh b/ui-actions.hh
index 10f23c4..0939ba9 100644
--- a/ui-actions.hh
+++ b/ui-actions.hh
@@ -17,7 +17,14 @@ struct T_KeyboardShortcut
 
 	// Writes the keyboard shortcut to a buffer
 	void toString( char* buffer , size_t size ) const noexcept;
+
+	// Equality operators
+	bool operator ==( T_KeyboardShortcut const& other ) const noexcept
+		{ return other.character == character && other.modifiers == modifiers; }
+	bool operator !=( T_KeyboardShortcut const& other ) const noexcept
+		{ return other.character != character || other.modifiers != modifiers; }
 };
+namespace ebcl { M_DECLARE_HASH( T_KeyboardShortcut ); }
 
 struct T_UIAction
 {
diff --git a/ui-app.cc b/ui-app.cc
index 659c6a7..fbc7130 100644
--- a/ui-app.cc
+++ b/ui-app.cc
@@ -19,6 +19,8 @@ static const ImWchar IconsRanges_[] = {
 };
 } // namespace <anon>
 
+/*----------------------------------------------------------------------------*/
+
 T_UIApp::T_UIApp( )
 {
 	SDL_Init( SDL_INIT_VIDEO | SDL_INIT_TIMER );
@@ -83,9 +85,22 @@ T_UIApp::~T_UIApp( )
 	SDL_Quit( );
 }
 
+/*----------------------------------------------------------------------------*/
+
 void T_UIApp::addAction(
 		T_UIAction action ) noexcept
 {
+	if ( action.shortcut ) {
+		const auto idx{ shortcuts_.indexOf( *action.shortcut ) };
+		if ( idx == T_HashIndex::INVALID_INDEX ) {
+			using namespace ebcl;
+			T_Set< T_String > nSet{ UseTag< ArrayBacked< 8 > >( ) };
+			nSet.add( action.id );
+			shortcuts_.add( *action.shortcut , std::move( nSet ) );
+		} else {
+			shortcuts_[ idx ].add( action.id );
+		}
+	}
 	actions_.set( std::move( action ) );
 }
 
@@ -107,10 +122,13 @@ void T_UIApp::actionButton(
 	}
 }
 
+/*----------------------------------------------------------------------------*/
+
 void T_UIApp::handleEvents( ) noexcept
 {
 	SDL_Event event;
 	mMove_ = ImVec2( );
+	kbKeys_.clear( );
 	while ( SDL_PollEvent( &event ) ) {
 		ImGui_ImplSdl_ProcessEvent( &event );
 		if ( event.type == SDL_QUIT ) {
@@ -123,13 +141,54 @@ void T_UIApp::handleEvents( ) noexcept
 			mMove_.x += event.motion.xrel;
 			mMove_.y += event.motion.yrel;
 		}
+
+		if ( event.type == SDL_KEYDOWN ) {
+			const auto sym{ event.key.keysym.sym };
+			if ( sym >= 0 && sym <= 127 ) {
+				kbKeys_.add( char( sym ) );
+			}
+		}
 	}
 
 	ImGui_ImplSdl_NewFrame( window_ , mCapture_ , mInitial_ );
-	ImGui::GetIO( ).MouseDrawCursor = true;
+
+	auto& io( ImGui::GetIO( ) );
+	io.MouseDrawCursor = true;
+	kbMods_ = ( ([&io]() {
+			T_KeyboardModifiers kb;
+			if ( io.KeyCtrl ) {
+				kb |= E_KeyboardModifier::CTRL;
+			}
+			if ( io.KeyShift ) {
+				kb |= E_KeyboardModifier::SHIFT;
+			}
+			if ( io.KeyAlt ) {
+				kb |= E_KeyboardModifier::ALT;
+			}
+			return kb;
+		})() );
+
+	handleKeyboardShortcuts( );
 	handleMouseCapture( );
 }
 
+void T_UIApp::render( ) noexcept
+{
+	handleDialogs( );
+	glUseProgram( 0 );
+	glBindProgramPipeline( 0 );
+	UI::Textures( ).reset( );
+	glClearColor( 0 , 0 , 0 , 1 );
+	ImGui::Render( );
+}
+
+void T_UIApp::swap( ) const noexcept
+{
+	SDL_GL_SwapWindow( window_ );
+}
+
+/*----------------------------------------------------------------------------*/
+
 void T_UIApp::handleMouseCapture( ) noexcept
 {
 	using namespace ImGui;
@@ -147,19 +206,6 @@ void T_UIApp::handleMouseCapture( ) noexcept
 			}
 			return mb;
 		})() );
-	const T_KeyboardModifiers kb( ([&io]() {
-			T_KeyboardModifiers kb;
-			if ( io.KeyCtrl ) {
-				kb |= E_KeyboardModifier::CTRL;
-			}
-			if ( io.KeyShift ) {
-				kb |= E_KeyboardModifier::SHIFT;
-			}
-			if ( io.KeyAlt ) {
-				kb |= E_KeyboardModifier::ALT;
-			}
-			return kb;
-		})() );
 	const bool appCanGrab( !( ImGui::IsMouseHoveringAnyWindow( )
 				|| io.WantCaptureMouse
 				|| io.WantCaptureKeyboard ) );
@@ -173,7 +219,7 @@ void T_UIApp::handleMouseCapture( ) noexcept
 
 	} else if ( mCapture_ && mDelegate_ ) {
 		SetMouseCursor( ImGuiMouseCursor_Move );
-		mDelegate_->handleDragAndDrop( mMove_ , kb , mb );
+		mDelegate_->handleDragAndDrop( mMove_ , kbMods_ , mb );
 
 	} else if ( appCanGrab && mb && mDelegate_ ) {
 		mCapture_ = true;
@@ -184,23 +230,34 @@ void T_UIApp::handleMouseCapture( ) noexcept
 	}
 
 	if ( ( appCanGrab || mCapture_ ) && io.MouseWheel && mDelegate_ ) {
-		mDelegate_->handleWheel( io.MouseWheel , kb , mb );
+		mDelegate_->handleWheel( io.MouseWheel , kbMods_ , mb );
 	}
 }
 
-void T_UIApp::render( ) noexcept
+void T_UIApp::handleKeyboardShortcuts( ) noexcept
 {
-	handleDialogs( );
-	glUseProgram( 0 );
-	glBindProgramPipeline( 0 );
-	UI::Textures( ).reset( );
-	glClearColor( 0 , 0 , 0 , 1 );
-	ImGui::Render( );
-}
+	auto const& io{ ImGui::GetIO( ) };
+	if ( io.WantCaptureKeyboard || io.WantTextInput || !modals_.empty( ) ) {
+		return;
+	}
 
-void T_UIApp::swap( ) const noexcept
-{
-	SDL_GL_SwapWindow( window_ );
+	for ( auto i = 0u ; i < kbKeys_.size( ) ; i ++ ) {
+		const T_KeyboardShortcut k{ kbKeys_[ i ] , kbMods_ };
+		auto const* const ksSet{ shortcuts_.get( k ) };
+		if ( !ksSet ) {
+			continue;
+		}
+
+		const auto nsk{ ksSet->size( ) };
+		for ( auto j = 0u ; j < nsk ; j ++ ) {
+			T_String const& s{ (*ksSet)[ j ] };
+			auto const* const a{ actions_.get( s ) };
+			if ( a && ( !a->enabled || a->enabled( ) ) ) {
+				a->handler( );
+				break;
+			}
+		}
+	}
 }
 
 void T_UIApp::handleDialogs( ) noexcept
diff --git a/ui-app.hh b/ui-app.hh
index 1594f42..218e1c7 100644
--- a/ui-app.hh
+++ b/ui-app.hh
@@ -3,6 +3,8 @@
 #include "ui-mousectrl.hh"
 #include "ui-actions.hh"
 
+#include <ebcl/Sets.hh>
+
 
 /*= WINDOW MANAGEMENT ========================================================*/
 
@@ -33,15 +35,6 @@ struct T_UIApp
 	void actionMenu( T_String const& id ) const noexcept;
 	void actionButton( T_String const& id ) const noexcept;
 
-	template< typename... Args >
-	T_UIAction& newAction( Args&&... args )
-	{
-		T_UIAction a{ std::forward< Args >( args ) ... };
-		T_String id{ a.id };
-		addAction( std::move( a ) );
-		return *actions_.get( id );
-	}
-
 	//----------------------------------------------------------------------
 
 	ImFont* defaultFont( ) const noexcept
@@ -78,6 +71,10 @@ struct T_UIApp
 	// Do we need to quit?
 	bool exiting_{ false };
 
+	// Keyboard events / state
+	T_KeyboardModifiers kbMods_;
+	T_AutoArray< char , 32 > kbKeys_;
+
 	// Mouse capture
 	bool mCapture_{ false };
 	ImVec2 mInitial_{ 0 , 0 };
@@ -92,8 +89,13 @@ struct T_UIApp
 		[]( T_UIAction const& a ) -> T_String {
 			return a.id;
 		} };
+	T_KeyValueTable< T_KeyboardShortcut , ebcl::T_Set< T_String > > shortcuts_;
 
+	// Event handling
 	void handleMouseCapture( ) noexcept;
+	void handleKeyboardShortcuts( ) noexcept;
+
+	// Modals
 	void handleDialogs( ) noexcept;
 };
 
diff --git a/ui-sync.cc b/ui-sync.cc
index bef5757..6dc98c0 100644
--- a/ui-sync.cc
+++ b/ui-sync.cc
@@ -12,7 +12,7 @@
 
 T_UISync::T_UISync( )
 {
-	UI::Main( ).newAction( "Save curves" , []() {
+	UI::Main( ).addAction( T_UIAction{ "Save curves" , []() {
 		if ( Common::Sync( ).curvesFileChanged( ) ) {
 			UI::Main( ).msgbox(
 				"Curves file changed" ,
@@ -27,12 +27,12 @@ T_UISync::T_UISync( )
 		} else {
 			Common::Sync( ).saveCurves( );
 		}
-	} ).setEnabledCheck( []() {
+	} }.setEnabledCheck( []() {
 		return Common::Sync( ).curvesModified( );
 	} ).setIcon( ICON_FA_FLOPPY_O )
-		.setShortcut( T_KeyboardShortcut{ 's' , E_KeyboardModifier::CTRL } );
+		.setShortcut( T_KeyboardShortcut{ 's' , E_KeyboardModifier::CTRL } ) );
 	//-----------------------------------------------------------------------------
-	UI::Main( ).newAction( "Reload curves" , []() {
+	UI::Main( ).addAction( T_UIAction{ "Reload curves" , []() {
 		UI::Main( ).msgbox(
 			"Reload curves?" ,
 			"Changes you made to the curves will be lost. Do you "
@@ -42,11 +42,11 @@ T_UISync::T_UISync( )
 					Common::Sync( ).loadCurves( );
 				}
 			} , { T_MessageBox::BT_YES , T_MessageBox::BT_NO } );
-	} ).setEnabledCheck( []() {
+	} }.setEnabledCheck( []() {
 		return Common::Sync( ).curvesModified( );
 	} ).setIcon( ICON_FA_DOWNLOAD )
 		.setShortcut( T_KeyboardShortcut{ 'r' ,
-				{ E_KeyboardModifier::CTRL , E_KeyboardModifier::SHIFT } } );
+				{ E_KeyboardModifier::CTRL , E_KeyboardModifier::SHIFT } } ) );
 	//-----------------------------------------------------------------------------
 	const auto addui{ [this]( char const* type , F_Override ov ) {
 		const bool ok{ sovuis_.add( T_String{ type } , std::move( ov ) ) };