Или окончательное решение цифирьного вопроса.
Ранее мы показывали простые способы, как обеспечить, чтобы в TextBox
можно было ввести только цифры, т.е. целое число (копия), а потом расширили пример до ввода в TextBox
отрицательных (копия) и дробных чисел (копия)
К сожалению, во всех этих примерах есть фатальный недостаток, текст в них все-таки вставить можно, если воспользоваться стандартным контекстным меню или комбинацией клавиш CTRL+V. На уровне простого взаимодействия с формой и контролами это перехватить невозможно, придется несколько извернуться, т.к. для перехвата события «вставить», придется перехватить сообщение Windows WM_PASTE
, которое отправляется окну, или элементу управления окна при выполнении операции вставки. Для Windows, тащемта, однохуйственно, кому отправлять сообщение, форме (окну) или, например, текстовому полю. Т.к. для Windows, и текстовое поле на самом деле окно, просто дочернее, т.е. размещенное в другом окне (форме, в нашем случае). Но это я залез глубоко в бок. Нам нужно добраться до сообщений. А это можно сделать только изнутри самого контрола, но не из событий стандартных контролов, так что будем писать свой!
Постановка задачи: необходимо создать свой контрол на основе текстового поля, который позволяет вводить только числа определенного типа — целые, целые отрицательные, отрицательные и положительные/отрицательные с дробной частью.
Подключим необходимые пространства имен:
using System.Windows.Forms;
using System.ComponentModel;
using System.Globalization;
Начнем делать свой контрол, наследуемый от TextBox
. Т.е. создадим новый класс:
public class InputDigitControl:TextBox { //тут будет код :) }
Для начала необходимо определить сообщения, которые будем перехватывать. Заводим в классе константы, определяющие коды нужных сообщений:
const int WM_PASTE = 0x0302; //Сообщение "Вставка" (через к.м. и комбинацию клавиш)
const int WM_CHAR = 0x0102; //Сообщение - нажатие алфавитно-цифровой клавиши
WM_CHAR
будет отправлено форме системой только тогда, когда будет нажата алфавитно-цифровая клавиша, его будем перехватывать для отслеживания цифр (и прочего). Правда есть важный нюанс — WM_CHAR
посылается и при нажатии комбинаций клавиш, например Ctrl+C и т.д., а также некоторых клавиш, которые не совсем подходят под понятие «алфавитно-цифровая», например BACKSPACE. Это надо будет учесть.
Анализ ввода с клавиатуры будем производить в функции PreProcessMessage()
, которую переопределим. PreProcessMessage()
вызывается для предварительной обработки входящих сообщений, и нужно будет вернуть true
, если это сообщение было обработано.
Т.е. алгоритм действий таков — мы проверяем входящий символ на соответствие, и, либо пропускаем его дальше (возвращаем base.PreProcessMessage(ref msg)
или false
), либо что-то делаем с содержимым текстового поля, если символ нужен (это понадобится при вводе отрицательных и дробей) и возвращаем true
. Или ничего не делаем, если символ нежелательный, и просто вызываем true
. В последнем случае, символ просто не попадет в поле ввода, т.к. контрол будет думать, что он уже обработан.
Вставку, как я уже говорил выше, тоже нужно будет обработать, но, естественно, несколько иначе, это будем делать в переопределенной функции WndProc()
В переопределенную функцию PreProcessMessage()
добавляем следующий код:
if (msg.Msg == WM_CHAR) //перехватываем сообщение WM_CHAR { {
в if
добавляем:
//была нажата комбинация клавиш
if ((ModifierKeys != Keys.None)&&
(ModifierKeys != Keys.Shift)) return false;
Control.ModifierKeys
— внутреннее свойство класса Control
, от которого наследуются элементы управления, оно позволяет определить, была ли нажата клавиша-модификатор (Ctrl, Alt или Shift). Для нашего случая (ввод цифр в TextBox
), нужно пропустить стандартные комбинации клавиш для TextBox
(CTRL+A, CTRL+C, CTRL+V, CTRL+X), чтобы не поломать работу TextBox
‘а. С комбинаций с SHIFT
для TextBox
нет никаких комбинаций, кроме заглавных букв и знаков препинания.
В WParam
сообщения Windows WM_CHAR
содержится UTF код символа, т.е. 32-битное число, преобразуем его в char
:
char chr = (char)msg.WParam.ToInt32();
И да, есть несколько служебных клавиш, воспринимаемых системой из-за древнего legacy (тянущегося еще с тех времен, когда мониторов не было, а вывод происходил на принтер) алфавитно-цифровыми, нам надо чтоб работала одна из них — BACKSPACE, добавляем:
if (chr == '\b') return false; //backspace
Клавиши управления курсором, HOME, END и DELETE алфавитно-цифровыми не считаются, так что будут работать и так, т.к. сообщение WM_CHAR
не будет посылаться контролу.
С вышеописанным это несложно сделать, в if (msg.Msg == WM_CHAR)
добавляем следующий код ниже:
//это цифры (ура, товарищи) if (chr >= '0' && chr <= '9') { return false; } else { return true; }
Т.е теперь у нас работают системные клавиатурные комбинации для TextBox
, BACKSPACE и ввод только цифр. Продолжаем.
Добавим в класс свойство, позволяющее включить или отключить ввод отрицательных чисел.
[Description("Enable or disable negative number input"),
Category("Behavior"), DefaultValue(false)]
public bool Negative { get; set; } //включает/отключает ввод отрицательных чисел
С помощью классов из пространства имен System.ComponentModel
можно добавить описание свойства, категорию, в которую будет помещено свойство, когда включен вид по категориям в Properties Window в редакторе, а также значение свойства по умолчанию.
После пересборки проекта, свойство появится в Properties Window
Ввод минуса, алгоритм:
1. Сохранить позицию курсора в текстовом поле.
2. Проверить, есть ли в начале строки знак ‘-‘
3.1. Если есть, его надо убрать, т.е. присвоить свойству this.Text
значение this.Text.Substring(1)
, это все символы кроме первого.
3.2. Надо вернуть курсор на прежнее место, т.е. на 1 символ меньше, т.к. был удален 1 символ: this.SelectionStart = pos - 1
4.1. Если нет — надо добавить: this.Text = "-" + this.Text
4.2. И переставить курсор на 1 позицию вперед: this.SelectionStart = pos + 1
.
Код:
//это цифры (ура, товарищи) if (chr >= '0' && chr <= '9') { return false; } else { //получаем текущую позицию курсора для вставки точки/минуса int pos = this.SelectionStart; //нажали минус, ввод отрицательных разрешен свойством Negative if (chr == '-' && Negative) { if (this.Text.StartsWith("-")) //минус уже есть { this.Text = this.Text.Substring(1);//убираем //ставим курсор на прежнюю позицию. //Т.е. на -1 от текущей, т.к. удалили 1 символ this.SelectionStart = pos - 1; } else //минуса нет { this.Text = "-" + this.Text; //добавили //переставили курсор this.SelectionStart = pos + 1; } } //конец ввод отрицательных return true; }
Т.е. надо вставлять разделитель целой и дробной части.
Добавим свойство, включающее и выключающее этот режим, как было в случае отрицательных:
[Description("Enable or disable fractional number input"),
Category("Behavior"), DefaultValue(false)]
public bool Fractional { get; set; } //включает/отключает ввод дробных чисел
Также, я решил добавить свойство, позволяющее задать разделитель (точку или запятую), а остальные запретить, ибо нефиг. Чтоб так сделать, можно генерировать исключение прямо в свойстве:
private char separator = '.'; [Description("Decimal separator, may be '.' or ','"), Category("Format"), DefaultValue('.')] //разделитель дробной и целой части числа public char Separator { get { return separator; } set { if ((value != '.') && (value != ',')) { throw new ArgumentOutOfRangeException("Separator", "Value must be '.' or ','"); } else { separator = value; } } }
Пересобираем проект, работает. Если попробовать ввести что-нибудь кроме точки или запятой в IDE — получим вот такое окно:
Переходим к вводу, алгоритм:
1. Поле ввода будет реагировать как на ввод точки, так и на ввод запятой в качестве разделителя (ибо заебало переключаться/вспоминать, что разделитель другой в зависимости от языка). Отображать поле будет разделитель, указанный в свойстве Separator
.
2. Проверяем, нет ли в тексте разделителя, если есть, отменяем ввод, вернув true
.
3. Если поле пустое, а введен разделитель, то добавим лидирующий 0 перед разделителем, а курсор переместим в конец строки.
3.1. Если нет, то в WParam запишем код разделителя из свойства контрола, вне зависимости от того, что было нажато, точка или запятая.
3.2. Проверим, не поставили ли разделитель в начале строки.
3.2.1 Если поставили, надо проверить, начинается ли текст с минуса и если да, отменить ввод — разделителя перед знаком «-
» не бывает.
3.2.2 Если минуса в начале строки нет, значит добавляем в начало текста 0
, разделитель, и перемещаем курсор на 2 символа от начала строки. Возвращаем true
.
3.3. Проверим, не начинается ли строка с символа «-
» и не введен ли разделитель, когда курсор стоит сразу после минуса.
3.3.1. Если да, вставляем в начало текста -0
, разделитель, и изначальный текст кроме первого символа (первым символом был минус), устанавливаем курсор после разделителя, возвращаем true
.
Код (после } //конец ввод отрицательных
и перед return true;
):
//ввод разделителя дробной части //поле реагирует и на . и на , if ((chr == '.' || chr == ',') && Fractional) { //проверяем, чтоб в строке не было двух разделителей if (this.Text.Contains(separator.ToString())) { return true; } //если поле пустое, добавляем 0 перед разделителем if (this.Text == string.Empty) { this.Text = "0" + separator.ToString(); //ставим курсор в конец текста this.SelectionStart = this.Text.Length; } else { //меняем WParam на код разделителя msg.WParam = (IntPtr)separator; //проверяем, не поставили ли разделитель //в начале текста if (this.SelectionStart == 0) { //если поставили и текст начинается с - //игнорируем нажатие, перед "-" //разделителя не бывает if (this.Text.StartsWith("-")) return true; //добавляем лидирующий 0 this.Text = "0" + separator.ToString() + this.Text; this.SelectionStart = 2; return true; } //если курсор стоит после "-" if ((this.SelectionStart == 1) && this.Text.StartsWith("-")) { //добавляем "-0," или "-0." к началу текста this.Text = "-0" + separator.ToString() + this.Text.Substring(1); this.SelectionStart = 3; return true; } return false; } }
Для начала надо переопределить WndProc
, общую функцию для любого контрола, куда попадает большинство оконных сообщений:
protected override void WndProc(ref Message m) { //тут будет код base.WndProc(ref m); }
В WndProc
перехватываем сообщение WM_PASTE
для которого мы выше заготовили константу.
protected override void WndProc(ref Message m) { if (m.Msg == WM_PASTE) //перехватываем сообщение "вставка" { //тут будет код } base.WndProc(ref m); }
Далее, необходимо перехватить данные из буфера обмена, а потом проверять их в зависимости от того, какие данные может принимать наш контрол.
//получаем строку из буфера обмена
IDataObject obj = Clipboard.GetDataObject();
string input = (string)obj.GetData(typeof(string));
//надо будет в дальнейшем
ulong tmpulong = 0;
long tmplong = 0;
Тут все просто, попытаемся сконвертировать содержимое буфера обмена в максимально возможный беззнаковый тип (UInt64
, он же ulong
) — получилось, разрешаем вставку, не получилось, пишем в Result
сообщения (IntPtr)0
, тем самым отменяя вставку, и выходим из функции.
if ((!Fractional) && (!Negative)) //только цифры { //пытаемся конвертировать в беззнаковый long if (!ulong.TryParse(input,out tmpulong)) { //не получилось m.Result = (IntPtr)0; //отменяем вставку return; } }
Тоже просто, действуем по вышеуказанному алгоритму, только конвертируем не в беззнаковый, а знаковый тип (Int64
, он же long
):
//отрицательные и положительные целые if ((!Fractional) && (Negative)) { //пытаемся конвертировать в знаковый long if (!long.TryParse(input,out tmplong)) { //не получилось m.Result = (IntPtr)0; //отменяем вставку return; } }
Тут немного сложнее, для начала придется написать функцию для их конверсии, которая учитывает разделитель точку и разделитель запятую:
1. Меняем разделитель на какой-нибудь один (пусть тот, который задан в свойстве Separator
контрола):
st = st.Replace('.', separator);
st = st.Replace(',', separator);
2. Создаем формат для функции конвертации, как это описано здесь (копия):
NumberFormatInfo format = new NumberFormatInfo();
format.NumberDecimalSeparator = separator.ToString();
3. Пытаемся сконвертировать, получилось — возвращаем true
, нет — false
:
try { double d = Convert.ToDouble(st, format); return true; } catch { return false; }
Теперь можно приступить к анализу буфера обмена:
1. Если дробные числа разрешены, пытаемся конвертировать в Double
, не получилось — отменяем вставку:
//пытаемся конвертировать в double
if (!IsDouble(input))
{
//не получилось
m.Result = (IntPtr)0; //отменяем вставку
return;
}
2. Заменяем разделитель на тот, который установлен в свойстве Separator
контрола:
//заменяем разделитель на установленный в контроле
input = input.Replace('.', separator);
input = input.Replace(',', separator);
3. Добавляем лидирующий 0
для положительных чисел, т.е. если строка начинается с разделителя, заменяем разделитель на 0
+ разделитель (например, на 0,
)
4. То же проделываем для отрицательных, т.е. заменяем -
+ разделитель (например -,
) на -0
:
//добавляем лидирующий 0 если надо if (input.StartsWith(separator.ToString())) { input = input.Replace(separator.ToString(), "0" + separator.ToString()); } if (input.StartsWith("-" + separator.ToString())) { input = input.Replace("-" + separator.ToString(), "-0" + separator.ToString()); }
Используя предыдущий алгоритм, просто добавляем проверку, чтоб строка в буфере не начиналась с минуса:
//дробные не отрицательные if (!Negative) { if (input.StartsWith("-")) { m.Result = (IntPtr)0; //отменяем вставку return; } }
В конце вставки дробных чисел меняем содержимое буфера обмена:
//меняем содержимое буфера обмена
Clipboard.SetText(input);
В конце функции вставки разрешаем вставлять числа только целиком, иначе это усложнит код (может допилю в будущих версиях), просто удаляя содержимое текстового поля, а вставку за нас система сделает:
//вставка чисел целиком
this.Text = string.Empty;