Last active
January 15, 2023 16:32
-
-
Save PlugFox/f233613aca7d5f998b53f52229d35b36 to your computer and use it in GitHub Desktop.
Dart Virtual Key Codes table and KeyboardObserver for win32 package, hotkey
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import 'dart:async'; | |
import 'dart:ffi' show Uint8, Uint8Pointer; | |
import 'package:ffi/ffi.dart' show calloc; // , malloc | |
import 'package:win32/win32.dart' | |
show GetKeyboardState, GetKeyState; // , GetAsyncKeyState; | |
import 'virtual_key_codes.dart'; | |
/// Key - Virtual Key codes | |
/// Value - KeyStatus pair | |
//@immutable | |
class KeyboardObserver extends Stream<Map<int, KeyStatus>> | |
implements Sink<int> { | |
final StreamController<Map<int, KeyStatus>> _controller; | |
late final StreamSubscription<void> _periodicSub; | |
static const int _kDefaultObserverTickPeriodInMS = 15; | |
final int _observerTickPeriodInMS; | |
final Set<int> _observableKeys; | |
/// TODO: [GetKeyboardState] return same values after first run | |
/// ISSUE: https://github.com/timsneath/win32/issues/153 | |
KeyboardObserver._({ | |
int tickPeriodInMS = _kDefaultObserverTickPeriodInMS, | |
}) : _observableKeys = <int>{for (int i = 1; i < 256; i++) i}, | |
_observerTickPeriodInMS = tickPeriodInMS, | |
_controller = StreamController<Map<int, KeyStatus>>.broadcast() { | |
final lpKeyboardState = calloc.allocate<Uint8>(256); | |
_periodicSub = | |
Stream<void>.periodic(Duration(milliseconds: _observerTickPeriodInMS)) | |
.listen( | |
(_) { | |
if (_observableKeys.isEmpty) return; | |
GetKeyboardState(lpKeyboardState); | |
final statuses = | |
Map<int, int>.of(lpKeyboardState.asTypedList(256).asMap()) | |
.map<int, KeyStatus>( | |
(key, value) => MapEntry( | |
key, | |
KeyStatus(key: key, value: value != 0 && value != 1), | |
), | |
)..removeWhere((key, _) => key < 1 || key > 255); | |
if (_observableKeys.length < 255) { | |
statuses..removeWhere((key, _) => !_observableKeys.contains(key)); | |
} | |
_controller.add(statuses); | |
}, | |
onDone: () { | |
calloc.free(lpKeyboardState); | |
}, | |
cancelOnError: false, | |
); | |
} | |
KeyboardObserver.withKeys({ | |
Iterable<int>? observableKeys, | |
int tickPeriodInMS = _kDefaultObserverTickPeriodInMS, | |
}) : _observableKeys = observableKeys == null | |
? <int>{} | |
: Set<int>.from(observableKeys.where((e) => e > 0 && e < 256)), | |
_observerTickPeriodInMS = tickPeriodInMS, | |
_controller = StreamController<Map<int, KeyStatus>>.broadcast() { | |
_periodicSub = | |
Stream<void>.periodic(Duration(milliseconds: _observerTickPeriodInMS)) | |
.listen( | |
(_) { | |
if (_observableKeys.isEmpty) return; | |
final statuses = _observableKeys.map<KeyStatus>( | |
(e) => KeyStatus( | |
key: e, | |
value: GetKeyState(e) != 0 && | |
GetKeyState(e) != 1, // GetAsyncKeyState(e) < 0, | |
), | |
); | |
_controller.add( | |
Map.fromEntries( | |
statuses.map<MapEntry<int, KeyStatus>>((s) => MapEntry(s.key, s)), | |
), | |
); | |
}, | |
cancelOnError: false, | |
); | |
} | |
@override | |
bool get isBroadcast => true; | |
@override | |
StreamSubscription<Map<int, KeyStatus>> listen( | |
void Function(Map<int, KeyStatus> event)? onData, | |
{Function? onError, | |
void Function()? onDone, | |
bool? cancelOnError}) => | |
_controller.stream.listen( | |
onData, | |
onError: onError, | |
cancelOnError: cancelOnError, | |
); | |
KeyboardObserver operator +(Object object) { | |
if (object is int && object > 0 && object < 256) { | |
_observableKeys..add(object); | |
} else if (object is KeyboardObserver) { | |
_observableKeys..addAll(object._observableKeys); | |
} else if (object is String) { | |
_observableKeys..addAll(object.keyCodes); | |
} | |
return this; | |
} | |
KeyboardObserver operator -(Object object) { | |
if (object is int && object > 0 && object < 256) { | |
_observableKeys..remove(object); | |
} else if (object is KeyboardObserver) { | |
_observableKeys..removeAll(object._observableKeys); | |
} else if (object is String) { | |
_observableKeys..removeAll(object.keyCodes); | |
} | |
return this; | |
} | |
/// Add a key to watch | |
@override | |
void add(int data) => | |
data > 0 && data < 256 ? _observableKeys.add(data) : null; | |
/// Remove key from observation | |
void remove(int data) => _observableKeys.remove(data); | |
@override | |
void close() { | |
_periodicSub.cancel(); | |
_controller.close(); | |
} | |
} | |
//@immutable | |
class KeyStatus implements Comparable<KeyStatus>, MapEntry<int, bool> { | |
/// Virtual key code | |
@override | |
final int key; | |
/// Pressed state | |
/// true - pressed | |
/// false - released | |
@override | |
final bool value; | |
//@literal | |
const KeyStatus({ | |
required this.key, | |
required this.value, | |
}); | |
KeyStatusResult where<KeyStatusResult extends Object>({ | |
required KeyStatusResult Function() pressed, | |
required KeyStatusResult Function() released, | |
}) => | |
value ? pressed() : released(); | |
@override | |
String toString() => | |
'<Virtual key code $key is ${value ? 'pressed' : 'released'}>'; | |
@override | |
bool operator ==(Object other) => | |
identical(this, other) || | |
(other is KeyStatus && other.key == key && other.value == value); | |
@override | |
int compareTo(KeyStatus other) => | |
key.compareTo(other.key) * 2 + | |
(value == other.value ? 0 : (value && !other.value ? 1 : 0)); | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import 'dart:async'; | |
import 'dart:io'; | |
import 'keyboard_observer.dart'; | |
import 'virtual_key_codes.dart'; | |
void main(List<String> arguments) => runZonedGuarded(_body, _onError); | |
Future<void> _body() async { | |
print('BEGIN'); | |
try { | |
final observer = KeyboardObserver.withKeys(observableKeys: 'WSDZTy'.keyCodes) + | |
VK_LSHIFT + | |
'QCRF'; | |
observer | |
..add(VK_A) | |
..remove(VK_Z); | |
observer + VK_LCONTROL - VK_SPACE - 'ty'; | |
await observer | |
.map<KeyStatus>((event) => event[VK_LSHIFT]!) | |
.distinct() | |
.skip(1) | |
.take(50) | |
.forEach(print); // first 50 statuses of "Shift" key | |
observer.close(); | |
} on TimeoutException { | |
print('END with TimeoutException'); | |
exit(2); | |
} | |
print('END'); | |
} | |
void _onError(Object error, StackTrace stack) => print(' * ERROR: $error'); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/* | |
* [V]irtual [K]ey codes table | |
* Symbolic constant name = decimal value 1..255 | |
*/ | |
/// Left mouse button | |
const int VK_LBUTTON = 1; | |
/// Right mouse button | |
const int VK_RBUTTON = 2; | |
/// Control-break processing | |
const int VK_CANCEL = 3; | |
/// Middle mouse button (three-button mouse) | |
const int VK_MBUTTON = 4; | |
/// Windows 2000: X1 mouse button | |
const int VK_XBUTTON1 = 5; | |
/// Windows 2000: X2 mouse button | |
const int VK_XBUTTON2 = 6; | |
/// BACKSPACE key | |
const int VK_BACK = 8; | |
/// TAB key | |
const int VK_TAB = 9; | |
/// CLEAR key | |
const int VK_CLEAR = 12; | |
/// ENTER key | |
const int VK_RETURN = 13; | |
/// SHIFT key | |
const int VK_SHIFT = 16; | |
/// CTRL key | |
const int VK_CONTROL = 17; | |
/// ALT key | |
const int VK_MENU = 18; | |
/// PAUSE key | |
const int VK_PAUSE = 19; | |
/// CAPS LOCK key | |
const int VK_CAPITAL = 20; | |
/// IME Kana mode | |
const int VK_KANA = 21; | |
/// IME Hanguel mode (maintained for compatibility; use VK_HANGUL) | |
const int VK_HANGUEL = 21; | |
/// IME Hangul mode | |
const int VK_HANGUL = 21; | |
/// IME Junja mode | |
const int VK_JUNJA = 23; | |
/// IME final mode | |
const int VK_FINAL = 24; | |
/// IME Hanja mode | |
const int VK_HANJA = 25; | |
/// IME Kanji mode | |
const int VK_KANJI = 25; | |
/// ESC key | |
const int VK_ESCAPE = 27; | |
/// IME convert | |
const int VK_CONVERT = 28; | |
/// IME nonconvert | |
const int VK_NONCONVERT = 29; | |
/// IME accept | |
const int VK_ACCEPT = 30; | |
/// IME mode change request | |
const int VK_MODECHANGE = 31; | |
/// SPACEBAR | |
const int VK_SPACE = 32; | |
/// PAGE UP key | |
const int VK_PRIOR = 33; | |
/// PAGE DOWN key | |
const int VK_NEXT = 34; | |
/// END key | |
const int VK_END = 35; | |
/// HOME key | |
const int VK_HOME = 36; | |
/// LEFT ARROW key | |
const int VK_LEFT = 37; | |
/// UP ARROW key | |
const int VK_UP = 38; | |
/// RIGHT ARROW key | |
const int VK_RIGHT = 39; | |
/// DOWN ARROW key | |
const int VK_DOWN = 40; | |
/// SELECT key | |
const int VK_SELECT = 41; | |
/// PRINT key | |
const int VK_PRINT = 42; | |
/// EXECUTE key | |
const int VK_EXECUTE = 43; | |
/// PRINT SCREEN key | |
const int VK_SNAPSHOT = 44; | |
/// INS key | |
const int VK_INSERT = 45; | |
/// DEL key | |
const int VK_DELETE = 46; | |
/// HELP key | |
const int VK_HELP = 47; | |
/// 0 key | |
const int VK_0 = 48; | |
/// 1 key | |
const int VK_1 = 49; | |
/// 2 key | |
const int VK_2 = 50; | |
/// 3 key | |
const int VK_3 = 51; | |
/// 4 key | |
const int VK_4 = 52; | |
/// 5 key | |
const int VK_5 = 53; | |
/// 6 key | |
const int VK_6 = 54; | |
/// 7 key | |
const int VK_7 = 55; | |
/// 8 key | |
const int VK_8 = 56; | |
/// 9 key | |
const int VK_9 = 57; | |
/// A key | |
const int VK_A = 65; | |
/// B key | |
const int VK_B = 66; | |
/// C key | |
const int VK_C = 67; | |
/// D key | |
const int VK_D = 68; | |
/// E key | |
const int VK_E = 69; | |
/// F key | |
const int VK_F = 70; | |
/// G key | |
const int VK_G = 71; | |
/// H key | |
const int VK_H = 72; | |
/// I key | |
const int VK_I = 73; | |
/// J key | |
const int VK_J = 74; | |
/// K key | |
const int VK_K = 75; | |
/// L key | |
const int VK_L = 76; | |
/// M key | |
const int VK_M = 77; | |
/// N key | |
const int VK_N = 78; | |
/// O key | |
const int VK_O = 79; | |
/// P key | |
const int VK_P = 80; | |
/// Q key | |
const int VK_Q = 81; | |
/// R key | |
const int VK_R = 82; | |
/// S key | |
const int VK_S = 83; | |
/// T key | |
const int VK_T = 84; | |
/// U key | |
const int VK_U = 85; | |
/// V key | |
const int VK_V = 86; | |
/// W key | |
const int VK_W = 87; | |
/// X key | |
const int VK_X = 88; | |
/// Y key | |
const int VK_Y = 89; | |
/// Z key | |
const int VK_Z = 90; | |
/// Left Windows key (Microsoft® Natural® keyboard) | |
const int VK_LWIN = 91; | |
/// Right Windows key (Natural keyboard) | |
const int VK_RWIN = 92; | |
/// Applications key (Natural keyboard) | |
const int VK_APPS = 93; | |
/// Computer Sleep key | |
const int VK_SLEEP = 95; | |
/// Numeric keypad 0 key | |
const int VK_NUMPAD0 = 96; | |
/// Numeric keypad 1 key | |
const int VK_NUMPAD1 = 97; | |
/// Numeric keypad 2 key | |
const int VK_NUMPAD2 = 98; | |
/// Numeric keypad 3 key | |
const int VK_NUMPAD3 = 99; | |
/// Numeric keypad 4 key | |
const int VK_NUMPAD4 = 100; | |
/// Numeric keypad 5 key | |
const int VK_NUMPAD5 = 101; | |
/// Numeric keypad 6 key | |
const int VK_NUMPAD6 = 102; | |
/// Numeric keypad 7 key | |
const int VK_NUMPAD7 = 103; | |
/// Numeric keypad 8 key | |
const int VK_NUMPAD8 = 104; | |
/// Numeric keypad 9 key | |
const int VK_NUMPAD9 = 105; | |
/// Multiply key | |
const int VK_MULTIPLY = 106; | |
/// Add key | |
const int VK_ADD = 107; | |
/// Separator key | |
const int VK_SEPARATOR = 108; | |
/// Subtract key | |
const int VK_SUBTRACT = 109; | |
/// Decimal key | |
const int VK_DECIMAL = 110; | |
/// Divide key | |
const int VK_DIVIDE = 111; | |
/// F1 key | |
const int VK_F1 = 112; | |
/// F2 key | |
const int VK_F2 = 113; | |
/// F3 key | |
const int VK_F3 = 114; | |
/// F4 key | |
const int VK_F4 = 115; | |
/// F5 key | |
const int VK_F5 = 116; | |
/// F6 key | |
const int VK_F6 = 117; | |
/// F7 key | |
const int VK_F7 = 118; | |
/// F8 key | |
const int VK_F8 = 119; | |
/// F9 key | |
const int VK_F9 = 120; | |
/// F10 key | |
const int VK_F10 = 121; | |
/// F11 key | |
const int VK_F11 = 122; | |
/// F12 key | |
const int VK_F12 = 123; | |
/// F13 key | |
const int VK_F13 = 124; | |
/// F14 key | |
const int VK_F14 = 125; | |
/// F15 key | |
const int VK_F15 = 126; | |
/// F16 key | |
const int VK_F16 = 127; | |
/// F17 key | |
const int VK_F17 = 128; | |
/// F18 key | |
const int VK_F18 = 129; | |
/// F19 key | |
const int VK_F19 = 130; | |
/// F20 key | |
const int VK_F20 = 131; | |
/// F21 key | |
const int VK_F21 = 132; | |
/// F22 key | |
const int VK_F22 = 133; | |
/// F23 key | |
const int VK_F23 = 134; | |
/// F24 key | |
const int VK_F24 = 135; | |
/// NUM LOCK key | |
const int VK_NUMLOCK = 144; | |
/// SCROLL LOCK key | |
const int VK_SCROLL = 145; | |
/// Left SHIFT key | |
const int VK_LSHIFT = 160; | |
/// Right SHIFT key | |
const int VK_RSHIFT = 161; | |
/// Left CONTROL key | |
const int VK_LCONTROL = 162; | |
/// Right CONTROL key | |
const int VK_RCONTROL = 163; | |
/// Left MENU key | |
const int VK_LMENU = 164; | |
/// Right MENU key | |
const int VK_RMENU = 165; | |
/// Windows 2000: Browser Back key | |
const int VK_BROWSER_BACK = 166; | |
/// Windows 2000: Browser Forward key | |
const int VK_BROWSER_FORWARD = 167; | |
/// Windows 2000: Browser Refresh key | |
const int VK_BROWSER_REFRESH = 168; | |
/// Windows 2000: Browser Stop key | |
const int VK_BROWSER_STOP = 169; | |
/// Windows 2000: Browser Search key | |
const int VK_BROWSER_SEARCH = 170; | |
/// Windows 2000: Browser Favorites key | |
const int VK_BROWSER_FAVORITES = 171; | |
/// Windows 2000: Browser Start and Home key | |
const int VK_BROWSER_HOME = 172; | |
/// Windows 2000: Volume Mute key | |
const int VK_VOLUME_MUTE = 173; | |
/// Windows 2000: Volume Down key | |
const int VK_VOLUME_DOWN = 174; | |
/// Windows 2000: Volume Up key | |
const int VK_VOLUME_UP = 175; | |
/// Windows 2000: Next Track key | |
const int VK_MEDIA_NEXT_TRACK = 176; | |
/// Windows 2000: Previous Track key | |
const int VK_MEDIA_PREV_TRACK = 177; | |
/// Windows 2000: Stop Media key | |
const int VK_MEDIA_STOP = 178; | |
/// Windows 2000: Play/Pause Media key | |
const int VK_MEDIA_PLAY_PAUSE = 179; | |
/// Windows 2000: Start Mail key | |
const int VK_LAUNCH_MAIL = 180; | |
/// Windows 2000: Select Media key | |
const int VK_LAUNCH_MEDIA_SELECT = 181; | |
/// Windows 2000: Start Application 1 key | |
const int VK_LAUNCH_APP1 = 182; | |
/// Windows 2000: Start Application 2 key | |
const int VK_LAUNCH_APP2 = 183; | |
/// Windows 2000: For the US standard keyboard, the ';:' key | |
const int VK_OEM_1 = 186; | |
/// Windows 2000: For any country/region, the '+' key | |
const int VK_OEM_PLUS = 187; | |
/// Windows 2000: For any country/region, the ',' key | |
const int VK_OEM_COMMA = 188; | |
/// Windows 2000: For any country/region, the '-' key | |
const int VK_OEM_MINUS = 189; | |
/// Windows 2000: For any country/region, the '.' key | |
const int VK_OEM_PERIOD = 190; | |
/// Windows 2000: For the US standard keyboard, the '/?' key | |
const int VK_OEM_2 = 191; | |
/// Windows 2000: For the US standard keyboard, the '`~' key | |
const int VK_OEM_3 = 192; | |
/// Windows 2000: For the US standard keyboard, the '[{' key | |
const int VK_OEM_4 = 219; | |
/// Windows 2000: For the US standard keyboard, the '\|' key | |
const int VK_OEM_5 = 220; | |
/// Windows 2000: For the US standard keyboard, the ']}' key | |
const int VK_OEM_6 = 221; | |
/// Windows 2000: For the US standard keyboard, | |
/// the 'single-quote/double-quote' key | |
const int VK_OEM_7 = 222; | |
/// | |
const int VK_OEM_8 = 223; | |
/// Windows 2000: Either the angle bracket key | |
/// or the backslash key on the RT 102-key keyboard | |
const int VK_OEM_102 = 226; | |
/// Windows 95/98, Windows NT 4.0, Windows 2000: IME PROCESS key | |
const int VK_PROCESSKEY = 229; | |
/// // Windows 2000: Used to pass Unicode characters | |
/// as if they were keystrokes. The VK_PACKET key is | |
/// the low word of a 32-bit Virtual Key value used | |
/// for non-keyboard input methods. For more information, | |
/// see Remark in KEYBDINPUT, SendInput, WM_KEYDOWN, and WM_KEYUP | |
const int VK_PACKET = 231; | |
/// Attn key | |
const int VK_ATTN = 246; | |
/// CrSel key | |
const int VK_CRSEL = 247; | |
/// ExSel key | |
const int VK_EXSEL = 248; | |
/// Erase EOF key | |
const int VK_EREOF = 249; | |
/// Play key | |
const int VK_PLAY = 250; | |
/// Zoom key | |
const int VK_ZOOM = 251; | |
/// Reserved for future use | |
const int VK_NONAME = 252; | |
/// PA1 key | |
const int VK_PA1 = 253; | |
/// Clear key | |
const int VK_OEM_CLEAR = 254; | |
extension VirtualKeyCodesX on String { | |
/// Convert [String] to [Iterable] of Keyboard virtual key codes | |
Iterable<int> get keyCodes => codeUnits | |
.map<int?>((e) => _codeUnitToVirtualKeyCode[e]) | |
.where((element) => element != null) | |
.whereType<int>(); | |
} | |
const Map<int, int> _codeUnitToVirtualKeyCode = <int, int>{ | |
// Spaces | |
9: VK_TAB, | |
32: VK_SPACE, | |
// Math | |
42: VK_MULTIPLY, | |
43: VK_ADD, | |
45: VK_SUBTRACT, | |
46: VK_DECIMAL, | |
47: VK_DIVIDE, | |
// Digits | |
48: VK_0, | |
49: VK_1, | |
50: VK_2, | |
51: VK_3, | |
52: VK_4, | |
53: VK_5, | |
54: VK_6, | |
55: VK_7, | |
56: VK_8, | |
57: VK_9, | |
// Upper case | |
65: VK_A, | |
66: VK_B, | |
67: VK_C, | |
68: VK_D, | |
69: VK_E, | |
70: VK_F, | |
71: VK_G, | |
72: VK_H, | |
73: VK_I, | |
74: VK_J, | |
75: VK_K, | |
76: VK_L, | |
77: VK_M, | |
78: VK_N, | |
79: VK_O, | |
80: VK_P, | |
81: VK_Q, | |
82: VK_R, | |
83: VK_S, | |
84: VK_T, | |
85: VK_U, | |
86: VK_V, | |
87: VK_W, | |
88: VK_X, | |
89: VK_Y, | |
90: VK_Z, | |
// Lower case | |
97: VK_A, | |
98: VK_B, | |
99: VK_C, | |
100: VK_D, | |
101: VK_E, | |
102: VK_F, | |
103: VK_G, | |
104: VK_H, | |
105: VK_I, | |
106: VK_J, | |
107: VK_K, | |
108: VK_L, | |
109: VK_M, | |
110: VK_N, | |
111: VK_O, | |
112: VK_P, | |
113: VK_Q, | |
114: VK_R, | |
115: VK_S, | |
116: VK_T, | |
117: VK_U, | |
118: VK_V, | |
119: VK_W, | |
120: VK_X, | |
121: VK_Y, | |
122: VK_Z, | |
// Other | |
124: VK_SEPARATOR, | |
}; |
TODO: GetKeyboardState vs GetKeyState benchmark
TODO: BlockInput();
TODO: SendInput()
Example:
const VK_A = 65;
final kbd = calloc<INPUT>()
..ref.type = INPUT_KEYBOARD
..ki.wVk = VK_A;
final result = SendInput(1, kbd, sizeOf<INPUT>());
if (result != TRUE) throw UnsupportedError('Can\'t send key $VK_A');
print(result == TRUE ? 'success' : 'fail');
calloc.free(kbd);
Example mouse click
void mouseLClick() {
final kbd = calloc<INPUT>();
try {
kbd
..mi.time = 0
..ref.type = INPUT_MOUSE
..mi.dwFlags = MOUSEEVENTF_LEFTDOWN | MOUSEEVENTF_LEFTUP;
final resultDown = SendInput(1, kbd, sizeOf<INPUT>());
if (resultDown != 1) throw UnsupportedError('Can\'t click');
print('success');
} finally {
calloc.free(kbd);
}
}
Future<void> mouseClickAsync(
[Duration delay = const Duration(milliseconds: 0)]) async {
final kbd = calloc<INPUT>(1);
try {
kbd
..ref.type = INPUT_MOUSE
..mi.dwFlags = MOUSEEVENTF_RIGHTDOWN;
final resultDown = SendInput(1, kbd, sizeOf<INPUT>());
if (resultDown != 1) throw UnsupportedError('Can\'t send keys $VK_RBUTTON');
await Future<void>.delayed(delay);
kbd
..ref.type = INPUT_MOUSE
..mi.dwFlags = MOUSEEVENTF_RIGHTUP;
final resultUp = SendInput(1, kbd, sizeOf<INPUT>());
if (resultUp != 1) throw UnsupportedError('Can\'t send keys $VK_RBUTTON');
print('success');
} finally {
calloc.free(kbd);
}
}
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Example: