GNOME3ApplicationDevelopmentBeginnersGuide:Chapter 06
- 제 6 장 위젯 생성하기
위젯 생성하기
앞 장에서 살펴보았듯이 GTK+는 표준 위젯을 제공한다. 어떤 경우에는 애플리케이션에서 GTK+ 표준 위젯만 이용하더라도 살아남을 수 있다. 하지만 애플리케이션 디자인과 요구사항이 더 복잡해질수록 자신만의 위젯을 구현해야 하는 일은 피할 수 없다. 특히 비슷한 디자인과 요구사항을 공유하는 여러 개의 프로젝트를 실행할 때 그러하다. 자신만의 위젯을 구현하지 않으면 결국 코드를 복사할 수 밖에 없어 결국 관리 측면에서 끔찍한 일이 될 것이다.
자신만의 위젯을 구현한다는 것은 본래 위젯에서 이용할 수 없는 기능을 추가하거나 제거하는 등 위젯의 맞춤설정을 의미하기도 한다. 이번 장에서는 처음부터 GTK+ 위젯을 생성하는 방법과 확장하는 방법까지 살펴볼 것이다. 또 Cairo canvas API를 이용하여 내부적으로 위젯을 그리는 방법을 논하고자 한다.
이번 장에서 구체적으로 다룰 주제는 다음과 같다.
- 위젯 오버라이드하기
- 위젯에 새로운 기능 추가하기
- 커스텀 위젯 구현하기
- 컴파일된 라이브러리에서 위젯 관리하기
시작해보자.
시작하기 전에
이번 장에서는 Glade를 사용하는 대신 raw GTK+ 프로그래밍을 사용할 것이다. 따라서 Vala 프로젝트는 제 3장, 프로그래밍 언어와 제 4장, GNOME 코어 라이브러리 사용하기에서 사용한 것과 동일한 설정을 사용할 것이다. 본문에서 생성하는 프로젝트는 Vala GTK+(간단한) 프로젝트이므로 GtkBuilder support for the user interface 를 비활성화하고 No license 옵션을 활성화한다.
위젯의 표준 함수 오버라이드하기
보통은 위젯의 프로퍼티에서 기본값을 다른 값으로 대체함으로써 위젯이 어떻게 행동하는지, 위젯이 어떻게 표시되는지를 맞춤설정한다. 위젯을 한 두 번 맞춤설정해서 끝날 일이라면 전혀 문제가 되지 않는다. 그저 객체로 인스턴스화하여 프로퍼티를 맞춤설정하면 된다. 하지만 위젯의 재사용을 원한다면 위젯을 우선 서브클래싱하여 서브클래스를 맞춤설정해야 한다. 서브클래싱이란 기존의 특정 위젯 클래스를 기반으로 새로운 위젯 클래스를 생성함을 의미한다.
실행하기 - set_title 함수 오버라이드하기
이제 GtkWindow를 서브클래싱하고, GtkWindow의 set_title 함수의 행위를 변경하길 원한다고 가정하자. 함수는 창의 제목을 인자에서 전달되는 특정 문자열로 설정하는 데에 사용된다. 본문에서는 우리가 set_title 함수를 설정할 때마다 창의 제목으로 특별한 단어를 추가하는 새로운 행위를 소개하고자 한다.
- custom-overriding이라는 새로운 Vala 프로젝트를 생성하라.
- src/custom_overriding.vala 파일에 아래 코드를 이용하라.
using GLib; using Gtk; public class CustomWindow : Window { public CustomWindow () { } public new void set_title(string newTitle) { title = "Custom: " + newTitle; } static int main (string[] args) { Gtk.init (ref args); var window = new CustomWindow (); window.set_title ("My window"); window.show_all (); window.destroy.connect(Gtk.main_quit); Gtk.main (); return 0; } }
- JavaScript에 상응하는 코드는 아래와 같다. 이것을 custom-overriding.js라는 스크립트 파일에 넣어라.
#!/usr/bin/env seed Gtk = imports.gi.Gtk; GObject = imports.gi.GObject; CustomWindow = new GType({ parent: Gtk.Window.type, name: "CustomWindow", class_init: function(klass, prototype) { prototype.set_title = function(newTitle) { this.title = "Custom: " + newTitle; } }, }); Gtk.init(Seed.argv); var window = new CustomWindow(); window.set_title("My window"); window.signal.destroy.connect(Gtk.main_quit); window.show_all(); Gtk.main();
- 위를 실행하고 아래 스크린샷과 같이 이름 앞에 Custom: word 가 자동으로 붙는지 확인하라.
무슨 일이 일어났는가?
Vala 코드를 먼저 살펴보자.
아래 선언을 통해서는 CustomWindow라는 새로운 클래스가 있으며 이는 Window에서 파생되었음을 알린다.
public class CustomWindow : Window
아래 행을 이용해 Gtk와 Glib 네임스페이스를 사용 중이므로 GtkWindow를 명시적으로 나타낼 필요는 없다.
using GLib;
using Gtk;
앞의 코드를 이용하면 Window의 전체 이름이 Gtk.Window이거나 Glib.Window가 될 가능성이 있다. 하지만 Glib.Window라는 것은 존재하지 않기 때문에 Gtk.Window에서 새로운 CustomWindow 클래스를 서브클래싱하고 있음을 꽤 확신할 수 있다.
이제 빈 생성자가 있는데, 이는 비어 있기 때문에 원한다면 생략해도 좋다.
public CustomWindow ()
{
}
아래는 set_title 이라 불리는 함수를 선언한다. 여기서는 set_title 함수를 우리 함수로 오버라이드한다.
public void set_title(string newTitle)
{
title = "Custom: " + newTitle;
}
오버라이드가 가능하려면 함수 인자(메서드 시그니처라고 불리기도 함)가 정확히 같아야 하는데, 같지 않을 경우 Vala는 아래 행을 이용해 우리가 함수를 호출하면 경고 메시지를 발생시킬 것이다.
window.set_title ("My window");
이 함수는 원본 Gtk.Window 클래스의 set_title 함수 대신에 호출된다. set_title 함수의 본체 내에서 Custom: 문자열로 title 프로퍼티의 값을 설정하고, 함수의 인자로 제공되는 newTitle과 결합(concatenate)한다.
애플리케이션에서 이를 다시 사용하고자 한다면 일반적인 Window 클래스 대신 아래 코드를 이용해 CustomWindow 클래스를 인스턴스화할 필요가 있다.
var window = new CustomWindow ();
그러면 새로 정의된 창 객체는 set_title 함수에 설정된 새로운 행위 뿐만 아니라 Gtk.Window로부터 비롯된 모든 기능들을 갖게 될 것이다.
Seed를 이용하면 아래 JavaScript 코드를 이용함으로써 set_title에 새로운 행위를 정의한다.
class_init: function(klass, prototype) {
prototype.set_title = function(newTitle) {
this.title = "Custom: " + newTitle;
}
}
이는 제 4장, GNOME 코어 라이브러리 사용하기에서 init 함수에 직접 새로운 함수를 넣을 때 학습한 내용과는 약간 다르다. 이번 장에서는 개념적으론 차이가 있지만 제 3장, 프로그래밍 언어에서 보인 바와 약간 비슷하게 prototype 안에 함수를 넣는다.
사실상 init 함수 안에서 직접 사용하는 수도 있다. 하지만 오버라이드된 함수인지 명확히 식별하고 가독성을 향상시키기 위해선 선언을 class_init의 prototype 매개변수로 넣어야 한다. 이는 class_init 함수 내에 가상 함수(짧게 말해, 파행된 클래스에서 오버라이드할 수 있는 함수들)로 연결하는 C-언어 코드에서 GObject가 작동하는 방식과 유사하다.
이것이 바로 JavaScript에서 행하는 방식인데, 제 4장의 GNOME 코어 라이브러리 사용하기에서 논한 바와 같이 언어 자체는 실제 OOP 언어가 아니기 때문이다. 사실 우리는 동시에 두 개의 장소에서 함수를 정의할 수도 있지만 init 함수에 정의된 함수만 사용될 것이다. 이는 class_init에서 생성된 함수는 모두 클래스가 생성될 때 생성되고, init에서 생성된 함수는 객체가 인스턴스화될 때 생성되기 때문이다. 따라서 class_init에 선언하는 함수들은 후에 객체가 생성되면 init의 함수들과 동일한 이름을 갖기 때문에 init에서 오버라이드된다.
새로운 기능 추가하기
일부 오래된 기능의 새로운 행위 뿐 아니라 위젯에 새로운 기능을 추가하길 원한다고 가정해보자. 라이브 검색을 실행하기 위한 내부 텍스트 엔트리를 갖고 있는 창을 구현한다고 치자. 해당 위젯은 키보드의 어떤 키든 누르는 즉시 텍스트 엔트리를 표시할 것이다. 텍스트 엔트리 내의 값은 후에 애플리케이션의 다른 개체(entity)가 라이브 검색을 실행 시 사용할 것이다.
실행하기 - 컴포짓 위젯 만들기
앞서 언급했듯이 창은 하나의 자식만 가질 수 있는데, 그렇다면 창의 실제 내용과 텍스트 엔트리는 어떻게 추가할 것인가? 이를 논해보자.
- custom-composite.js라고 불리는 스크립트를 생성하고 아래 코드로 채워라.
#!/usr/bin/env seed Gtk = imports.gi.Gtk; GObject = imports.gi.GObject; CustomWindow = new GType({ parent: Gtk.Window.type, name: "CustomWindow", signals: [ { name: "search-updated", parameters: [GObject.TYPE_STRING] } ], class_init: function(klass, prototype) { prototype.show_search_box = function() { this.entry.show(); this.entry.has_focus = true; } prototype.hide_search_box = function() { this.entry.hide(); } prototype.super_add = prototype.add; prototype.add = function(widget) { if (widget != this.box) { this.box.pack_start(widget, true, true); } else { this.super_add(widget); } } }, init: function(self) { this.box = new Gtk.Box(); this.box.orientation = Gtk.Orientation.VERTICAL; this.entry = new Gtk.Entry(); this.add(this.box); this.box.pack_start(this.entry, false, true); this.box.show(); this.entry.signal.key_release_event.connect(function(obj, event) { self.signal.search_updated.emit(self.entry.text); return false; }); this.signal.key_press_event.connect(function(obj, event) { if (!self.entry.get_visible()) { self.show_search_box(); } return false; }); } }); Gtk.init(Seed.argv); var window = new CustomWindow(); var label = new Gtk.Label({label:'This is a text'}); window.add(label); window.resize(400, 400); window.signal.connect('search-updated', function(object, value) { label.set_text('Searching for keyword: ' + value); }); label.show(); window.show(); Gtk.main();
- 아니면 custom-composite이라는 Vala 프로젝트를 생성하여 src/custom_composite.vala를 아래와 같은 코드 조각으로 채울 수도 있다.
using GLib; using Gtk; public class CustomWindow : Window { Entry entry; Box box; public signal void search_updated(string value); void show_search_box() { entry.show(); entry.has_focus = true; } void hide_search_box() { entry.hide(); } public override void add(Widget widget) { if (widget != box) { box.pack_start(widget, true, true); } else { base.add(widget); } } public CustomWindow () { box = new Box(Orientation.VERTICAL, 0); entry = new Entry(); box.pack_start (entry, false, true); box.show(); add(box); key_release_event.connect((event) => { search_updated(entry.text); return false; }); key_press_event.connect((event) => { if (!entry.get_visible()) { show_search_box(); } return false; }); } static int main (string[] args) { Gtk.init (ref args); var window = new CustomWindow(); var label = new Label("This is a text"); window.add(label); window.resize(400,400); window.search_updated.connect((value) => { label.set_text("Searching for keyword " + value); }); label.show(); window.show(); Gtk.main (); return 0; } }
- 프로그램을 실행하고 아래 스크린샷과 같이 입력하라.
무슨 일이 일어났는가?
방금 컴포짓 위젯, 즉 몇 개의 위젯으로 구성된 위젯을 생성하였다. GtkWindow, GtkBox, GtkEntry를 이용해 새로운 CustomWindow 위젯이 만들어졌고, 애플리케이션에 노출되는 인터페이스는 GtkWindow에서 비롯된 인터페이스가 유일하다. 이는 우리가 확장하고자 하는 위젯이 GtkEntry 클래스 또는 GtkBox 클래스가 아니라 GtkWindow 클래스이기 때문이며, 그에 따라 GtkWindow를 기반 클래스로 선택한다.
사용자가 텍스트 엔트리에 무언가를 입력하면 트리거되는 새로운 시그널도 추가한다. 이 시그널은 텍스트 엔트리의 내용을 출력하므로 검색 엔진과 같은 곳에 데이터를 제공할 때 유용하게 사용된다.
JavaScript 코드를 먼저 살펴보자.
CustomWindow = new GType({
parent: Gtk.Window.type,
name: "CustomWindow",
앞의 클래스 정의는 새로운 CustomWindow 클래스가 GtkWindow의 서브클래스이며 CustomWindow로 명명함을 Seed로 알려준다.
이제 search-updated라는 새로운 타입 문자열의 시그널을 선언한다.
signals: [{
name: "search-updated",
parameters: [GObject.TYPE_STRING]
}],
이 시그널을 이용해 텍스트 엔트리는 추후 처리에 사용 가능한 새로운 데이터를 포함하고 있음을 알려준다.
전체적인 이야기를 더 쉽게 알리기 위해 init 함수로 먼저 시작해보자.
init: function(self) {
this.box = new Gtk.Box();
this.box.orientation = Gtk.Orientation.VERTICAL;
this.entry = new Gtk.Entry();
여기서는 창의 두 개의 지원 객체, GtkBox와 GtkEntry를 init에서 선언한다. 이 시점에서 CustomWindow 객체가 생성될 예정이다. 이 객체를 class_init로 넣어선 안 되는데, class_init 함수는 클래스가 생성될 때 한 번만 호출되기 때문이다. 우리는 현재 CustomWindow 객체가 생성될 때 두 개의 지원 객체가 생성되길 원하기 때문에 init 함수에 넣는다.
다시 돌아와서 GtkEntry는 CustomWindow 에 넣길 바란다. 하지만 하나의 자식 위젯만 가질 수 있다는 창의 한계점 때문에 CustomWindow 클래스에서 컨테이너의 역할을 인수하도록 GtkBox를 사용한다. 위젯을 수직적으로 배치하고 GtkEntry를 가장 위에 놓는다.
아래 행을 이용해 박스를 창에 직접 추가한다.
this.add(this.box);
그렇다면 애플리케이션에서 또 다른 위젯을 창으로 추가하길 원한다면 어떤 일이 발생할까? CustomWindow 내용은 이미 box 객체로 채워져 있지 않은가? 맞다. 그렇다면 CustomWindow 클래스에 위젯을 넣는 대신 box 객체로 추가하는 무언가를 생각해내야 한다. 이것은 add 함수를 재정의함으로써 class_init에서 실행하고자 한다. 간략하게 살펴보겠다.
다음은 GtkEntry 클래스를 패킹하고 box 객체를 표시할 차례다.
this.box.pack_start(this.entry, false, true);
this.box.show();
아래 코드 조각에서는 key-release-event 이벤트 핸들러를 추가한다. 따라서 키 누름을 해제할 때마다 텍스트 엔트리에서 일부 데이터를 이용할 수 있음을 표시하는 시그널을 발생시킨다.
this.entry.signal.key_release_event.connect(function(obj, event) {
self.signal.search_updated.emit(self.entry.text);
return false;
});
우리는 텍스트 엔트리의 내용을 전달함으로써 시그널을 트리거한다. 성능과 관련된 이유로 키 누름 이벤트에서 실행하는 대신 키 누름해제 이벤트에서 실행한다. 무언가를 입력할 때는 타이핑이 끝나야, 즉 키 누름이 해제되어야만 텍스트가 준비되었음을 알 수 있다. 키 누름 이벤트에서 시그널을 트리거할 경우, 가령 다수의 중복된 문자를 텍스트 엔트리로 입력하기 위해 키를 길게 누른다 치면 시그널은 계속해서 발생할 것인데, 이 때 그다지 효과적이지 않은 검색 엔진을 사용 중이라면 이 과정에서 애플리케이션의 속도를 저하시킬 수도 있다.
여기서는 애플리케이션에서 키를 누를 때마다 초기에 텍스트 엔트리를 표시하도록 key-press-event 이벤트를 사용한다.
this.signal.key_press_event.connect(function(obj, event) {
if (!self.entry.get_visible()) {
self.show_search_box();
}
return false;
});
메서드 선언을 포함하는 class_init로 이동해보자.
class_init: function(klass, prototype) {
prototype.show_search_box = function() {
this.entry.show();
this.entry.has_focus = true;
}
prototype.hide_search_box = function() {
this.entry.hide();
}
class_init 함수에 새로운 유틸리티 함수를 추가하므로, 후에 객체가 생성될 때는 함수가 준비되어 있을 것이다. 앞의 함수들은 텍스트 엔트리를 표시하고(키보드 포커스를 잡는 것을 포함해) 숨길 것이다. 아래는 GtkWindow의 add 함수를 수정하는 것과 관련된다.
prototype.super_add = prototype.add;
prototype.add = function(widget) {
if (widget != this.box) {
this.box.pack_start(widget, true, true);
} else {
this.super_add(widget);
}
}
init 함수에서 보았듯이 박스를 CustomWindow 클래스에 추가한다. 앞의 함수에서는 특별한 처리 방법이 있기 때문에 추가된 위젯이 우리의 box 객체일 경우 그저 기반 클래스에서 함수를 호출하면 되는데, 기본적으로 이 함수는 GtkWindow의 원본 add 함수에 해당한다. 이를 위해서는 먼저 super_add 변수에 원본 함수를 저장해야 한다. 함수를 저장하고 나면 추가된 위젯을 박스로 패킹함으로써 add 함수를 재정의한다.
애플리케이션에서 새로운 CustomWindow 위젯을 활용하는 방법을 살펴보자. 아래 코드를 통해 CustomWindow 클래스로부터 새로운 객체를 선언한다.
Gtk.init(Seed.argv);
var window = new CustomWindow();
이후 새로운 라벨을 생성하여 창으로 추가한다. 여기서 라벨은 내부 박스 객체로 패킹됨을 주목한다.
var label = new Gtk.Label({label:'This is a text'});
window.add(label);
그리고 아래의 행이 있다.
window.resize(400, 400);
앞의 코드 행에서는 resize 함수를 선언하진 않았지만 기반 클래스, 즉 GtkWindow 클래스에서 그냥 이용할 수 있음을 알 수 있다. 따라서 꼭 GtkWindow에서 선언될 필요는 없으며, GtkWindow 클래스의 부모 클래스, 심지어 GtkWindow 클래스의 부모의 부모 클래스에서 선언되어도 괜찮다는 뜻이다.
아래 코드는 새로운 시그널을 사용하는 방법을 보여준다.
window.signal.connect('search-updated', function(object, value) {
label.set_text('Searching for keyword: ' + value);
});
시그널이 발생할 때마다 우리는 라벨 안에 텍스트 엔트리의 내용을 표시한다. 실제 애플리케이션에서는 검색 엔진으로 값을 제공할 것인데, 데이터베이스 내 특정 데이터를 검색하거나 문서에서 단어를 검색하는 방법 중 택할 수 있다.
다음으로 Vala 코드를 분석해보자. 여기서는 먼저 CustomWindow 클래스가 Window 클래스로부터 서브클래싱된다고 말한다.
public class CustomWindow : Window
entry 와 box 위젯을 아직까지 건드리지 않았음을 기억하고, 이제 이들을 선언한다.
Entry entry;
Box box;
이후 새로운 시그널을 선언한다. Vala에서는 시그널에 대한 단어 구분자로 대시(dash) 대신 밑줄을 사용함을 기억하라.
public signal void search_updated(string value);
add 함수를 여기에서 정의하고, 위젯이 박스가 아닐 경우 위젯을 패킹한다.
public override void add(Widget widget) {
if (widget != box) {
box.pack_start(widget, true, true);
} else {
base.add(widget);
}
}
위젯이 박스가 맞다면 원본(original) GtkWindow 클래스의 add 함수를 사용한다. 간단히 base.add() 를 사용하고, JavaScript 코드처럼 별도로 번거로운 작업을 하지 않아도 된다.
이 함수가 부모 클래스로부터 원본 add 함수를 오버라이드한다는 사실을 나타내기 위해 override 키워드를 사용한다. 해당 키워드가 없이는 함수는 완전히 새로운 함수이자 부모 클래스의 add 함수와 연관되지 않은 함수로 간주된다.
나머지 코드 부분은 꽤 간단하고 JavaScript 코드에서 앞서 실행한 것과 유사하다.
호환성 유지하기
CustomWindow 클래스를 자세히 살펴보면 GtkWindow와 호환성을 어긴다는 사실을 확인할 수 있다. GtkWindow는 하나의 자식 위젯만 취할 수 있지만 우리가 사용한 CustomWidget 클래스는 하나 이상의 위젯을 취할 수 있는데, 위젯들이 우리의 내부 박스로 패킹될 것이기 때문이다. GtkWindow와 호환성을 유지하기 위해서는 CustomWindow 클래스 내부에 이미 하나의 자식이 있을 때마다 잇따른 추가 요청을 거부함으로써 이러한 문제를 해결할 수 있다.
remove 함수를 재정의해야 한다는 것도 잊어버렸기 때문에 자식 위젯을 제거하고자 할 때마다 실패할 것인데, 그 이유는 자식 위젯이 CustomWindow 클래스가 아니라 내부 box 객체로 저장되기 때문이다. GtkWindow 클래스의 remove 함수는 이를 거부할 것인데, 이는 자식의 부모가 (내부 박스) 더 이상 같지 않기 때문이다 (CustomWindow).
이 문제는 해결이 가능하다. 첫 번째 문제의 경우, 내부 box 객체에 있는 자식의 개수를 확인할 수 있다. 새로운 위젯을 추가했을 때 자식의 개수가 2개(GtkEntry 클래스와 자식)를 넘으면 요청을 거부한다. 두 번째 문제에 대해서는 remove 함수를 재정의하고 내부 box 객체로부터 자식 위젯을 제거하면 된다.
내부 박스 객체에 자식의 개수를 얻기 위해서는 this.box.get_children().length 를 이용할 수 있다. |
시도해보기 - 엔트리 사용 후 숨기기
hide_search_box 함수를 준비해봤지만 아직 사용한 적은 없다. 한 가지 좋은 생각이 있다. Esc 키를 누르면 검색 박스를 숨겨보는 것이 어떤가?
key-press-event 핸들러에서 event.key.keyval의 값을 검사하여 Esc 키를 확인할 수 있다. |
GTK+ 커스텀 위젯 구현하기
다음 단계는 현재 존재하는 위젯으로부터 확장하는 것이 아니라 처음부터 새로운 위젯을 생성함으로써 커스텀 위젯을 구현해볼 차례다. GTK+ 표준 위젯에서 비슷한 위젯을 찾을 수 없을 때 이 방법을 이용하면 우리가 구현하고자 하는 위젯에서 필요로 하는 바를 성취할 수 있다.
이는 Vala에서만 가능한 일인데, 안타깝게도 본 서적을 집필하는 동안 사용된 Seed의 버전은 클래스로부터 호출될 때 함수의 오버라이드를 적절하게 처리할 수 없다.
본문에 소개된 예제는 사실상 방금 논한 실제 상황에는 영향을 미치지 않는데, 처음부터 위젯을 구현하는 대신 쉽게 사용이 가능한 위젯을 찾는 일이 더 간단하기 때문이다. 하지만 예제를 제공한 이유는 새로운 위젯을 구현하는 데에 수반되는 노력과 그 방법을 소개하기 위함이다.
아래의 요구사항을 충족하는 위젯이 필요하다고 가정해보자.
- 위젯은 직사각형 등의 데코레이션을 영역 내에 그릴 수 있어야 한다.
- 마우스로 위젯을 클릭하면 색상이 변하면서 누름 상태가 되었음을 나타낸다.
- 마우스 누름을 해제하면 색상이 일반 상태의 색으로 돌아간다.
- 마우스로 위젯을 클릭하면 위젯이 활성화되었음을 알려주는 시그널이 발생하고, 마우스 누름을 해제하면 이제 위젯이 비활성화되었음을 알려주는 시그널이 트리거된다.
실행하기 - 커스텀 위젯 구현하기
그렇다면 이제 앞에 소개한 디자인에 따라 구현해보자.
- custom-new라는 새로운 Vala 프로젝트를 생성하고, src/custom_new.vala를 편집하여 아래 코드로 채워라.
using GLib; using Gtk; public class CustomWidget : DrawingArea { StateFlags state; const int MARGIN = 20; public signal void activated(); public signal void deactivated(); void update_state (int newState) { switch (newState) { case 1: state = StateFlags.SELECTED; break; case 0: default: state = StateFlags.NORMAL; break; } queue_draw (); } public override bool draw(Cairo.Context cr) { StyleContext style = get_style_context (); style.set_state (state); int w = get_allocated_width (); int h = get_allocated_height (); Gtk.render_background (style, cr, 0, 0, w, h); cr.rectangle (MARGIN, MARGIN, w - (MARGIN * 2), h - (MARGIN * 2)); cr.stroke (); return true; } public CustomWidget() { update_state (0); add_events (Gdk.EventMask.BUTTON_PRESS_MASK | Gdk.EventMask.BUTTON_RELEASE_MASK); button_press_event.connect((e) => { update_state (1); activated(); return true; }); button_release_event.connect((e) => { update_state (0); deactivated(); return true; }); } static int main (string[] args) { Gtk.init (ref args); var window = new Window(); var widget = new CustomWidget(); window.add (widget); window.show_all (); Gtk.main (); return 0; } }
- 프로그램을 실행하고 앞서 명시한 요구사항을 테스트하라.
무슨 일이 일어났는가?
코드를 보면 처음부터 자신만의 위젯을 구현하기란 꽤 수월해 보인다. 자세히 살펴보자.
public class CustomWidget : DrawingArea
클래스 선언을 보면 GtkDrawingArea를 기반 클래스로 사용 중임을 확인할 수 있다. GtkDrawingArea 클래스는 빈 위젯으로, 아무 일도 하지 않고 아무 것도 표시하지 않는다. 이 위젯의 특징은 그저 그 위에 무언가를 그릴 수 있다는 것이다. GTK+ 용어로 말하자면 이 위젯은 그리기가 가능하다(drawable)고 말할 수 있겠다. GtkDrawingArea를 선택한 이유는 우리의 요구사항에 일치하기 때문이며, 개발자들이 처음부터 새로운 위젯을 구현할 때 사용하는 일반적인 위젯이기 때문이다.
StateFlags state;
아래는 위젯의 누름 상태를 보유하게 될 변수다.
const int MARGIN = 20;
이는 2.0의 값을 보유하는 MARGIN이라는 상수다. 위젯의 변으로부터 직사각형의 여백으로 사용될 것이다. 전체적인 GNOME 프로젝트에서는 상수의 이름을 모두 대문자로 작성하는 규칙이 사용되므로 GNOME 애플리케이션 개발을 할 때는 항상 이러한 규칙을 따르는 편이 좋다.
위젯에서 제공하는 두 개의 시그널은 다음과 같다.
public signal void activated();
public signal void deactivated();
이는 위젯의 상태를 저장한 후 queue_draw를 호출하는 유틸리티 함수다.
void update_state (int newState)
{
switch (newState) {
case 1: state = StateFlags.SELECTED;
break;
case 0:
default:
state = StateFlags.NORMAL;
break;
}
queue_draw ();
}
state 변수는 기본적으로 상태(state)에 대한 고유의 해석을 보유하여 위젯의 조건(condition)으로 매핑한다. 요구조건을 다시 살펴보면, 위젯을 클릭하거나 위젯으로부터 마우스를 해제하면 색상이 변경되길 원한다. 여기서 색상을 상태와 매핑한다. 어떤 것도 누르지 않으면(혹은 마우스 누름을 해제하면) 상태를 NORMAL 상태로 설정한다. 반대로 마우스를 누르면 상태를 SELECTED 상태로 선택한다. 이 두 가지는 NORMAL, SELECTED, ACTIVE, INSENSITIVE 등 여러 상태가 포함된 열거의 일부에 해당한다.
여러 상태에 대한 정보는 DevHelp의 GtkStateFlags에서 확인할 수 있다. |
이러한 상태를 마음대로 매핑해도 좋지만 매핑할 때는 상식을 이용해야 한다. 즉, 위젯이 눌러지면 우리는 위젯이 사용자와 상호작용을 할 수 없거나 비활성화되었다고 생각하는 대신 위젯이 활성화 또는 선택되었거나 포커스를 받았다고 생각할 것이다. 그리고 이전 플래그를 이용해 이런 가상의 상태에 가장 가까운 의미를 찾을 것이다. 따라서 누름 상태의 위젯에서는 SELECTED 를 이용해 매핑하고, 눌러지지 않은 위젯은 NORMAL 상태로 매핑한다.
함수의 끝에 보면 위젯이 그림을 새로고침하도록 만들기 위해 queue_draw를 호출한다. 기본적으로는 GTK+에게 가능한 한 빨리 draw 함수를 호출할 것을 요청하는 셈이다.
위젯의 시각적 모양을 책임지는 draw 함수의 모습은 다음과 같다.
public override bool draw(Cairo.Context cr) {
이 함수에서는 위젯에 어떤 것이든 그릴 수 있다. 인수에서 전달되는 시스템에서 얻는 것은 Cairo Context 객체다. Cairo는 GTK+가 위젯을 그리기 위해 사용하는 벡터 기반의 캔버스 시스템이다. Context는 GKT+에 의해 생성된 핸들 객체로, 우리는 이를 이용해 무엇을 그릴지, 어디에 그릴지 등을 제어할 수 있다. 이는 HTML5의 캔버스와 비슷하다.
요구조건을 다시 살펴보면, 상태에 따라 위젯의 색상을 채워야 한다고 언급되어 있다. 그렇다면 어떤 색상을 이용해야 할까? 우리는 위젯에 직접적으로 색상을 선언할 필요가 없다 (그래선 안 된다!). 대신 GTK+ 테마 시스템(theming system)에 의존해야 한다.
이 함수에서 가장 먼저 하는 일은 스타일 컨텍스트를 얻는 일이다.
StyleContext style = get_style_context ();
style.set_state (state);
컨텍스는 무엇보다 특정 상태에서 사용되는 패턴이나 색상을 제어한다. 이는 update 함수에서 설정하는 상태다. 이후 상태 정보를 이용해 컨텍스트를 설정한다. 그리고 나면 스타일 컨텍스트는 명시된 상태로 자동 전환할 것이다.
render_background 함수를 호출하면 위젯 내 명시된 영역을 색상 또는 패턴으로 채울 것이다.
int w = get_allocated_width ();
int h = get_allocated_height ();
Gtk.render_background (style, cr, 0, 0, w, h);
이런 경우 0,0 좌표부터 시작해 위젯에 할당된 전체 너비와 높이 크기만큼 색상 또는 패턴으로 모두 채운다. 따라서 색상 또는 패턴이 위젯 전체를 채울 것이라고 기대한다. 실제 사용되는 색상이나 패턴은 GTK+ 테마에 명시된다. 테마가 NORMAL 상태의 색상이 파란색이라고 말하면 GTK+는 파란색으로 위젯을 채울 것이다.
요구조건에 따라 배경을 그리고 나면 직사각형으로 전경(foreground)을 그려야 한다. 이 때는 단순히 MARGIN의 값, 즉 20 픽셀 가까이 축소된 크기의 위젯으로 직사각형을 그리면 된다. 직사각형은 위젯 영역 내에 그려질 것으로 예상한다.
cr.rectangle (MARGIN, MARGIN,
w - (MARGIN * 2),
h - (MARGIN * 2));
Cairo에서는 많은 명령을 제공할 수 있지만 실제로 이러한 명령을 실행하기 전에는 어떤 것도 렌더링되지 않는다. 앞에서 소개한 rectangle 명령과 같은 테두리 그리기(drawing stroke) 명령의 경우 캔버스로 그리기를 실행하려면 stroke 함수를 이용한다.
cr.stroke ();
그러면 위젯에 직사각형이 그려진다.
함수로부터 true 값을 리턴하는 것은 GTK+ 시스템의 다른 부분에서 더 이상 어떠한 처리도 필요로 하지 않는다는 뜻이다.
return true;
그렇다면 draw 함수를 호출하는 것은 무엇일까? 많은 요인들이 있는데, 앞서 논한 queue_draw도 그 중 하나다. 위젯을 포함하는 창의 크기를 조정한다거나, 또 다른 창이 위젯의 일부를 가리는 경우도 해당할 수 있겠다. 구체적으로 말하자면, GTK+가 위젯을 다시 그려야 한다고 판단하게 만드는 것이라면 무엇이든 draw 함수를 트리거할 것이다. draw 함수는 항상 반복해서 호출 가능하기 때문에 함수의 내용은 가능한 한 작게 주의하여 디자인해야 한다. 위젯의 만족스러운 시각적 모양을 얻기 위해서는 draw 함수에서 느리게 처리되는 복잡한 계산이나 호출 함수는 피해야 한다. 가령 위젯이 애니메이션을 표시할 경우 부드럽고 유체적인 애니메이션을 얻기 위해서는 60 frames per second(fps)의 프레임률을 가져야 한다. 즉, 초당 draw 함수가 60회 호출될 것으로 예상된다는 의미다. 이는 draw 함수가 16.67 밀리초 이내에 실행되어야 한다는 뜻이기도 하다. 이를 충족할 수 없다면 애니메이션은 버벅거리고 사용자에게 불쾌한 경험을 유발하기도 한다.
이제 생성자를 살펴보도록 하자.
public CustomWidget()
{
update_state (0);
여기서 state 변수를 0으로 초기화하는데, 마우스 누름이 처음에 발생하지 않았기 때문에 위젯이 NORMAL 상태로 그려질 것으로 예상된다는 뜻이다.
아래 코드는 생성자 내에서 호출되어야 한다. 이는 버튼 누름 및 누름해제 이벤트가 발생할 때마다 위젯으로 이벤트를 전달할 것을 GTK+ 시스템으로 요청한다.
add_events (Gdk.EventMask.BUTTON_PRESS_MASK
| Gdk.EventMask.BUTTON_RELEASE_MASK);
이를 실행하지 않으면 위젯은 누름 상태인지 아닌지를 알지 못하기 때문에 아래 두 개의 이벤트 핸들러가 애초에 호출되지 않을 수도 있다.
button_press_event.connect((e) => {
update_state (1);
activated();
return true;
});
button_release_event.connect((e) => {
update_state (0);
deactivated();
return true;
});
이러한 이벤트 핸들러에서는 true를 리턴함으로써 처리가 최종적이며 GTK+가 이벤트 파이프라인에 있는 다른 위젯들에게 이벤트를 전달하는 것은 원치 않음을 나타낸다.
처음부터 위젯을 생성하는 방법(이번 예제에서 소개)과 기존 위젯으로부터 서브클래싱하는 방법(앞의 예제에서 소개)의 차이는 생성자에 있는 이벤트 구독과 draw 함수에 있음을 주목해야 한다. 사실 기존 위젯을 서브클래싱하면서 자신만의 그리기(drawing) 함수를 구현하는 수도 있다. 어떤 경우에서는 draw 함수에서 base.draw()를 호출함으로써 원본 그리기 함수와 자신만의 그리기 함수를 섞어 사용하기도 한다.
라이브러리에서 위젯 관리하기
많은 프로젝트에서 사용되는 커스텀 위젯을 몇 가지 갖고 있어서 그로부터 라이브러리를 만든다면 소스 코드를 복사하는 수고와 위젯의 수정 여부를 굳이 추적할 필요가 없을 것이다. 위젯을 라이브러리로 모으면 여러 프로젝트에 사용 가능한 하나의 라이브러리와 하나의 소스 코드를 유지할 수 있다.
제 3장, 프로그래밍 언어에서는 JavaScript 코드의 모듈화를 논한 바 있는데 이는 그 앞에 소개한 원칙과 개념적으로 동일하다. Vala를 이용하면 더 많은 수고를 필요로 하지만, 최종적으로 보면 라이브러리에 위젯을 모을 수 있다는 장점을 확보할 수 있다.
실행하기 - 라이브러리 생성하기
Vala에서 이것을 어떻게 실현하는지에 집중하자. 두 개의 프로젝트를 생성하는데, 하나는 라이브러리 예제용이고 나머지 하나는 그 라이브러리의 사용자를 위한 것으로, 아래 단계를 따라한다.
- 먼저 custom-library라는 Vala 프로젝트를 생성하자.
- src/custom_library.vala에서 custom-new 프로젝트의 custom_new.vala 파일로부터 코드를 채운다. 코드를 복사하고 붙여 넣은 후 아래와 같이 몇 가지만 조정한다.
- 클래스 선언을 네임스페이스 선언으로 자동 줄바꿈(wrap)하여 코드가 아래와 같은 모양이 되도록 한다.
namespace CustomWidget { public class CustomWidget : DrawingArea
- 코드 끝에 닫는 중괄호를 넣는 것을 잊지 말라.
- 클래스로부터 static main 함수를 제거하라.
- Files dock를 이용해 src/Makefile.am 파일의 전체 코드를 아래의 코드로 대체하라.
AM_CPPFLAGS = \ -DPACKAGE_LOCALE_DIR=\""$(localedir)"\" \ -DPACKAGE_SRC_DIR=\""$(srcdir)"\" \ -DPACKAGE_DATA_DIR=\""$(pkgdatadir)"\" \ $(CUSTOM_LIBRARY_CFLAGS) AM_CFLAGS =\ -Wall\ -g lib_LTLIBRARIES = libcustomwidget.la libcustomwidget_la_SOURCES = \ custom_library.vala config.vapi libcustomwidget_la_VALAFLAGS = \ --pkg gtk+-3.0 --library=libcustomwidget -X -fPIC -X -shared -H custom_widget.h libcustomwidget_la_LDFLAGS = \ -Wl,--export-dynamic
- 클래스 선언을 네임스페이스 선언으로 자동 줄바꿈(wrap)하여 코드가 아래와 같은 모양이 되도록 한다.
- Shift+F7 키 조합을 눌러 프로젝트를 만들어라. 오류가 없도록 하고, src 디렉터리에서 custom_widget.h 와 libcustomwidget.vapi 를 찾고 .libs 디렉터리에서 라이브러리 파일 집합을 찾아라. 라이브러리를 사용할 준비가 되었음을 뜻한다.
- 앞서 생성한 라이브러리를 사용하는 방법에 대한 예로 custom-library-client라는 프로젝트를 하나 더 생성하라. src/custom_library_client.vala 를 아래 코드로 채워라.
using GLib; using Gtk; using CustomWidget; public class Main : Object { public Main () { Window window = new Window(); var w = new CustomWidget.CustomWidget(); window.set_title ("Hello custom widget"); window.add(w); window.show_all(); window.destroy.connect(on_destroy); } public void on_destroy (Widget window) { Gtk.main_quit(); } static int main (string[] args) { Gtk.init (ref args); var app = new Main (); Gtk.main (); return 0; } }
- 프로젝트 내에 lib/ 디렉터리를 생성하고, custom-library 프로젝트로부터 src/custom_widget.h와 src/libcustomwidget.vapi 파일, 그리고 이름 앞에 .libs/libcustomwidget.so 가 붙은 파일을 모두 찾아라. 그 파일들을 방금 생성한 lib/ 디렉터리로 복사하라.
- src/Makefile.am 을 아래의 내용으로 대체하라.
AM_CPPFLAGS = \ -DPACKAGE_LOCALE_DIR=\""$(localedir)"\" \ -DPACKAGE_SRC_DIR=\""$(srcdir)"\" \ -DPACKAGE_DATA_DIR=\""$(pkgdatadir)"\" \ $(CUSTOM_LIBRARY_CLIENT_CFLAGS) AM_CFLAGS =\ -Wall\ -g -I../lib/ bin_PROGRAMS = custom_library_client custom_library_client_SOURCES = \ custom_library_client.vala config.vapi custom_library_client_VALAFLAGS = \ --pkg gtk+-3.0 --pkg libcustomwidget --vapidir ../lib custom_library_client_LDFLAGS = \ -Wl,--export-dynamic -lcustomwidget -L../lib custom_library_client_LDADD = $(CUSTOM_LIBRARY_CLIENT_LIBS)
- Run 메뉴를 열고 Program Parameters 를 선택하라. 대화상자가 나타나면 Environment Variables 박스를 확장시켜라.
- LD_LIBRARY_PATH라는 새로운 엔트리를 생성하고 값 필드에 방금 생성한 lib/ 디렉터리의 전체 경로를 입력하라. 가령, 필자의 컴퓨터에서는 /home/gnome/src/ custom-libraryclient 에 custom-library-client 프로젝트가 있기 때문에 값 필드에 /home/gnome/src/ custom-library-client/lib라고 입력하였다.
- 이제 애플리케이션을 실행할 수 있다. 앞의 예제와 비슷하지만 이번에는 창의 제목이 아래 스크린샷과 같이 표시된다.
무슨 일이 일어났는가?
라이브러리 프로젝트에서 클래스를 CustomWidget 네임스페이스로 감쌌다. 이 때문에 우리는 클라이언트 애플리케이션에 아래의 행을 넣을 수 있다.
Using CustomWidget;
라이브러리에서는 static main 함수를 제거한다. 라이브러리는 절대 main 함수를 가져선 안 되기 때문에 무조건 제거해야 한다. 애플리케이션의 진입점으로 정확히 하나의 main 함수만 존재해야 한다는 사실은 앞서 살펴본 바가 있는데, 이번 경우는 custom-library-client 프로젝트가 진입점이다.
이후 애플리케이션을 만드는 대신 라이브러리를 생성하고자 함을 Vala와 C 컴파일러로 알리기 위해 Makefile.am 을 수정하였다.
아래의 행은 libcustomwidget.la 의 이름으로 라이브러리를 생성하고자 하는 의도를 알려준다.
lib_LTLIBRARIES = libcustomwidget.la
실제로는 이로 인해 libcustomwidget.la와 libcustomwidget.so 라이브러리 파일의 집합이 생성될 것이다.
이후 아래의 행을 통해 잇따른 파일들로부터 libcustomwidget 라이브러리가 빌드됨을 알린다.
libcustomwidget_la_SOURCES = \
custom_library.vala config.vapi
아래 실린 코드는 Vala 코드를 C-언어 소스 코드로, 그리고 마침내 Binary 실행 파일로 컴파일 시 언급된 이름으로 라이브러리를 만들어 -fPIC -shared 를 C 컴파일러로 전달하고 싶음을 나타낸다 (-x 는 -x 다음에 언급된 플래그라면 무엇이든 C 컴파일러로 전달하기 위한 옵션이다).
libcustomwidget_la_VALAFLAGS = \
--pkg gtk+-3.0 --library=libcustomwidget -X -fPIC -X -shared -H custom_widget.h
앞의 두 플래그는 라이브러리를 생성할 때 가장 중요한 플래그로, 라이브러리를 공유할 수 있도록 해주고 메모리 내 어떤 위치에서든 로딩 가능하게 만들어준다 (PIC 는 위치 독립 코드(Position-independent Code)를 뜻한다). 마지막으로 custom_widget.h 라는 C 헤더 파일을 생성하였음을 컴파일러로 알린다. 이 파일은 클라이언트 프로젝트에서 C 컴파일러가 필요로 하는 파일이다.
다음으로, 이 프로젝트에는 main 함수가 없기 때문에 프로젝트를 실행하는 대신 빌드한다. 결과 파일들은 시스템 전체적으로 /usr/include(custom_widget.h의 경우), /usr/lib(libcustomwidget.so. * 파일의 경우), /usr/share/vala/vapi(libcustomwidget.vapi 파일의 경우) 중 한 곳으로 복사하거나, 클라이언트 프로젝트의 lib 디렉터리에 모두 복사해야 한다.
클라이언트 프로젝트로 넘어가자.
여기서 유일하게 흥미로운 부분은 바로 src/Makefile.am 파일에 있다.
--pkg libcustomwidget은 추가 라이브러리로 명시한다. 또 libcustomwidget.vapi는 ../lib 디렉터리에서 이용 가능할 것이라고 명시한다.
custom_library_client_VALAFLAGS = \
--pkg gtk+-3.0 --pkg libcustomwidget --vapidir ../lib
Vala는 위의 코드가 없다면 .vapi 파일을 어디서 찾아야 할지 알 수 없지만 단, 개발자가 시스템 전체에 파일을 설치하는 경우는 제외된다.
아래의 파일 일부는 링커(linker)와 관련된 것으로, ../lib/libcustomwidget.so* 에서 찾을 수 있는 libcustomwidget 라이브러리를 이용해 심볼(symbol)을 결정한다.
custom_library_client_LDFLAGS = \
-Wl,--export-dynamic -lcustomwidget -L../lib
애플리케이션을 실행하는 동안 LD_LIBRARY_PATH를 libcustomwidget.so.* 파일을 포함하는 전체 경로로 설정한다. 시스템 전체에 라이브러리가 설치되어 있다면 이럴 필요가 없다.
마지막으로 .vapi 파일, .h 파일, 라이브러리 파일을 전송하고, 애플리케이션이 쉽게 검색 및 사용이 가능하도록 시스템 전체적으로 설치해야 하는데, 이제 GTK+ 라이브러리를 매우 쉽게 사용할 수 있기 때문이다. 이러한 라이브러리 접근법을 이용 시 장점은, 문제가 되는 프로그래밍 언어로 라이브러리와 헤더 파일이 결합되어 있는 경우 Vala 프로그램만 라이브러리를 이용할 수 있는 것이 아니라 C 프로그램이나 다른 프로그램 언어들과 함께 사용할 수도 있다는 데에 있다.
요약
이번 장에서는 기존의 위젯을 확장하는 방법, 기존의 위젯을 새로운 위젯으로 결합하는 방법, 새로운 위젯을 생성하는 방법에 대해 학습하였다. 기존의 위젯에 기능을 추가하거나 그로부터 제거하기란 꽤 수월하다는 사실을 확인할 수 있었다. 또 위젯을 확장할 때는 위젯을 원본 인터페이스의 이전 버전과 호환이 가능하도록 만드는 것이 매우 중요하다는 사실도 알게 됐다.
커스텀 위젯을 생성하면서 Cairo 캔버스를 이용한 그림 그리기를 잠시 논한 바 있다. GTK+ 스타일링 API를 간략하게 다루면서 색상 입히기나 다른 페인트 스타일은 테마 시스템에 의존하고, 프로그램에서 하드코딩을 피할 수 있었다. 또 페인팅 함수를 최적으로 유지하면 성능을 양호하게 유지할 수 있음을 논했다.
마지막으로, 위젯의 생성은 라이브러리의 생성과도 결부된다. Vala에서 위젯을 생성하는 방법을 다루어보았다. 지금까지 경험을 토대로 이미 만들어진 위젯을 고를 뿐 아니라 새로운 위젯을 확장 및 생성하여 애플리케이션으로 결합함으로써 GNOME 애플리케이션을 개발하는 데에 더 자신감을 얻게 되었다.
다음 장에서는 GStreamer를 이용한 멀티미디어 프로그래밍을 논하고자 한다. 미디어 파일을 갖고 놀고 조작하기 위해 GStreamer 프레임워크를 활용하는 방법도 논할 것이다.