Pull to refresh

Первая программа для OS X своими руками — менеджер буфера обмена

Reading time 6 min
Views 27K
Больше года прошло с тех пор, как я увлекся программированием под платформу iOS. Наконец-то я нашел свободное время попробовать свои силы на платформе OS X. Если вы давно испытываете интерес к платформе OS X, но никак не соберетесь начать, эта статья для вас! Под катом подробное описание процесса создания приложения — менеджера буфера обмена. Все исходники можно найти на github.com/k06a/Clipshare



Подготавливаем проект к работе


Не долго думая, я создал в Xcode 5 проект типа Cocoa Application с именем «Clipshare». В свежесозданном проекте, мы можем наблюдать следующие файлы:



Файлы ABAppDelegate.h и ABAppDelegate.m относятся к классу делегата приложения, и в них мы как раз будем писать весь наш код. В файле MainMenu.xib мы будем рисовать и настраивать графический интерфейс приложения.

Хорошенько подумав, решаем что наше приложение должно быть безоконным и должно висеть в строке статуса (возле часиков). Первое что нам потребуется, удалить из файла MainMenu.xib стандартную строку меню и объект окна — они нам не потребуются. Далее создаем объект меню с 2 пунктами: разделителем и пунктом выхода из приложения. Это делается простым перетаскиванием объектов из библиотеки на холст. Для работы пункта меню Quit достаточно с зажатой клавишей Ctrl протянуть от пункта меню до объекта приложения Application синюю нить мышкой:



После того как мы отпустим клавишу мыши, появится окошко со списком селекторов (методов), доступных для соединения. Среди представленных методов, нам подходит -terminate:



Теперь необходимо суметь обратиться к нашему меню из кода. Для того чтобы настроить его отображение в статусной строке OS X и много еще для чего. Откроем режим ассистанта, когда в окне Xcode видно сразу 2 документа, в первом оставим — графический интерфейс, а во втором выберем исходный код приложения: ABAppDelegate.h. И также как и в прошлый раз с зажатой клавишей Ctrl протянем синюю нить от меню, но теперь к исходному коду. Протянуть нужно в секцию кода @interface в файле ABAppDelegate.h (свойство window я из этого файла уже стер за ненадобностью):



Как только мы отпустим зажатую клавишу мыши, выскочит диалоговое окно с настройками создаваемого Outlet (свойство в коде, ссылающееся на объект графического интерфейса). Остается только указать имя свойства, например «menu»:



Теперь переключаемся на исходный код приложения. В файле ABAppDelegate.m у нас имеется всего 1 метод и тот пустой:

- (void)applicationDidFinishLaunching:(NSNotification *)aNotification
{
   // ...
}

Внутри этого метода нем предстоит вписать код, в первую очередь выполняющий привязку нашего меню к строке статуса OS X:

self.statusBar = [[NSStatusBar systemStatusBar] statusItemWithLength:NSVariableStatusItemLength];
self.statusBar.title = @"CS";
self.statusBar.menu = self.menu;
self.statusBar.highlightMode = YES;

Для работоспособности этого кода необходимо также объявить свойство statusBar в секции @interface:

@property (strong, nonatomic) NSStatusItem *statusBar;

Чтобы уже можно было проверить наше приложение, нужно всего лишь добавить 1 ключик со значением YES в файл Clipshare-Info.plist, он позволит нашему приложению работать без окна:



Запускаем наше приложение и наблюдаем его в строке статуса OS X и даже можем выйти из него по Cmd-Q или просто ткнув в единственный пункт меню:



Внедряем основную логику


Предлагаю проверять содержимое буфера обмена по таймеру каждые полсекунды и в зависимости от того, что мы в нем найдем — выполнять те или иные действия. Судя по всему, нет другого способа узнать об изменениях в буфере обмена, а жаль ( stackoverflow.com/a/5033480/440168 ). Ок, настраиваем таймер. Создаем объект таймера, указываем ему какой метод и как часто он должен вызывать и добавляем таймер в цикл обработки сообщений:

NSTimer * timer = [NSTimer timerWithTimeInterval:0.5 target:self selector:@selector(timerFire:) userInfo:nil repeats:YES];
[[NSRunLoop currentRunLoop] addTimer:timer forMode:NSDefaultRunLoopMode];

Теперь циклом обработки сообщений по таймеру будет происходить вызов селектора (метода) -timerFire: у объекта self. Не забудем этот метод реализовать:

- (void)timerFire:(id)sender
{
    NSPasteboard * pboard = [NSPasteboard generalPasteboard];
    NSPasteboardItem * pboardItem = [[pboard pasteboardItems] lastObject];
    NSString * text = [pboardItem stringForType:NSPasteboardTypeString];

    // ...
}

В теле метода, мы обращаемся к главному буферу обмена, к последнему объекту в нем содержащемуся и пытаемся извлечь из него текстовые данные. Предлагаю запоминать изменения буфера обмена в массиве предыдущих значений, а также хранить параллельный массив ( ru.wikipedia.org/wiki/Параллельный_массив ) дат и времени соответствующих изменений:

@property (strong, nonatomic) NSMutableArray * texts;
@property (strong, nonatomic) NSMutableArray * times;

Не забываем в начале работы программы инициализировать оба свойства пустыми пассивами:

self.texts = [NSMutableArray array];
self.times = [NSMutableArray array];

Теперь реализуем следующую логику: текст обнаруженный в буфере обмена мы поищем в массиве с предыдущими значениями и если такой НЕ найдется — добавим его в начало массива и добавим новый пункт в начало меню:

NSInteger index = [self.texts indexOfObject:text];

// ...

NSMenuItem * menuItem = [[NSMenuItem alloc] initWithTitle:@"" action:@selector(menuItemSelect:) keyEquivalent:@""];
[self.menu insertItem:menuItem atIndex:0];
[self.texts insertObject:text atIndex:0];
[self.times insertObject:[NSDate date] atIndex:0];

Если такой текст уже есть в массиве с предыдущими значениями, будем ставить галочку у соответствующего пункта нашего меню и ничего более. Для этого заведем новое свойство selectedIndex типа BOOL:

if (index != NSNotFound)
{
    self.selectedIndex = index;
    [self updateItemTitlesAndStates];
    return;
}


Метод updateItemTitlesAndStates занимается обходом всех пунктов меню и обновлением их названий (в названиях указано время, прошедшее с момента копирования в буфер) и выставлением галочки слева от нужного пункта selectedIndex:

- (void)updateItemTitlesAndStates
{
    for (int i = 0; i < self.menu.itemArray.count-2; i++)
    {
        NSDate * time = self.times[i];
        NSString * text = self.texts[i];
        NSMenuItem * menuItem = self.menu.itemArray[i];
        
        NSString * timeStr = nil;
        NSTimeInterval secs = MAX(0,[[NSDate date] timeIntervalSinceDate:time]);
        if (secs < 60)
            timeStr = [NSString stringWithFormat:@"%ds",(int)(secs)];
        else if (secs < 60*60)
            timeStr = [NSString stringWithFormat:@"%dm",(int)(secs/60)];
        else if (secs < 60*60*24)
            timeStr = [NSString stringWithFormat:@"%dh",(int)(secs/60/60)];
        else if (secs < 60*60*24*7)
            timeStr = [NSString stringWithFormat:@"%dd",(int)(secs/60/60/24)];
        else if (secs < 60*60*24*365.75)
            timeStr = [NSString stringWithFormat:@"%dw",(int)(secs/60/60/24/7)];
        else if (secs < 60*60*24*365.75*3)
            timeStr = [NSString stringWithFormat:@"%dM",(int)(secs/60/60/24/30.5)];
        else if (secs < 60*60*24*365.75*100)
            timeStr = [NSString stringWithFormat:@"%dy",(int)(secs/60/60/24/365.75)];
        else
            timeStr = @"..";
        
        menuItem.title = [NSString stringWithFormat:@"(%@) \"%@%@\"", timeStr,
                          [text substringToIndex:MIN(MaxVisibleChars,text.length)],
                          (text.length <= MaxVisibleChars) ? @"" : @"..."];
        menuItem.state = (i == self.selectedIndex) ? NSOnState : NSOffState;
        menuItem.keyEquivalent = [@(i+1) description];
    }
}

Ну и в случае добавления нового пункта нужно еще удалять старые пункты, если их слишком много:

while (self.menu.itemArray.count >= MaxVisibleItems+2)
{
    [self.menu removeItemAtIndex:self.menu.itemArray.count-3];
    [self.texts removeLastObject];
    [self.times removeLastObject];
}

Осталось обработать клики по пунктам меню:

- (void)menuItemSelect:(id)sender
{
    NSInteger index = [self.menu.itemArray indexOfObject:sender];
    
    NSPasteboard * pboard = [NSPasteboard generalPasteboard];
    [pboard clearContents];
    NSPasteboardItem * pboardItem = [[NSPasteboardItem alloc] init];
    [pboardItem setString:self.texts[index] forType:NSPasteboardTypeString];
    [pboard writeObjects:@[pboardItem]];
}


Вот теперь приложение, работает как мы хотели! Когда в буфер обмена попадает текст, который уже был там недавно, галочка перескакивает вниз на нужный пункт. То же происходит, если мы кликаем по этому пункту сами:




Приложение рассчитано только на текстовые данные, и не работает должным образом с картинками, файлами и прочими вещами попадающими в буфер обмена, но я сам им пользуюсь уже пару дней. Это первое приложение написанное мной за пару часов — которое я реально использую. Спасибо тем, кто прочитал и просмотрел все картинки, надеюсь вам понравилось!

За магические числа и параллельные массивы в коде, прошу не пинать! Как было проще и быстрее так и написал. Кто любит по фэн-шую всё — жду ваш пул-реквест!

Исходники приложения найти можно здесь: github.com/k06a/Clipshare
Бинарник можно скачать из релизов: github.com/k06a/Clipshare/releases

P.S. Если не будете скупиться на плюсы и комментарии, в следующей статье создадим приложение под iOS и будем синхронизировать буфер обмена между устройствами!

P.P.S. Кто найдет в тексте эпическую опечатку, получит плюс в карму!
Tags:
Hubs:
+71
Comments 45
Comments Comments 45

Articles