Qt 로 만들자: 시간표

<환율계산기> 이후로 꽤 오래 쉬었다. 간간히 책에 대한 글을 올리기는 했지만, Qt 프로그램에 쓸 시간이 부족했다. 다행히 시간적 여유가 생겨 다시 시작한다. 이번에는 <시간표> 이다.

PMS 나 다이어리 수준은 아니고, 학창 시절에 필요했던 요일과 수업시간만 있는 간단한 시간표이다.

1. 요구사항

  • 요일 이름을 바꿀 수 있다.
  • 수업 시간을 바꿀 수 있다.
  • 시간표를 저장하고 읽을 수 있다.

참, 간단하다. ^^

2. 코드 작성

2.1 프로젝트 작성

  • 프로젝트 이름 : TimeTable
  • 메인 클래스 이름 : Exchange TimeTable
  • 메인 클래스 유형 : QMainWindow

2.2 헤더 분석(timetable.h)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
/** @file timetable.h
 */


#ifndef TIMETABLE_H
#define TIMETABLE_H

#include <QMainWindow>
#include <QtWidgets>

/**
 * @brief 시간표 클래스
 */

class TimeTable : public QMainWindow
{
    Q_OBJECT

public:
    TimeTable(QWidget *parent = 0);
    ~TimeTable();

protected:
    void closeEvent(QCloseEvent *e) Q_DECL_OVERRIDE;

private:
    int _tableRows;             ///< 시간표 세로줄 수
    int _tableCols;             ///< 시간표 가로줄 수
    QTableWidget *_timeTable;   ///< 시간표를 위한 테이블 위젯
    QString _fileName;          ///< 현재 파일 이름
    bool _named;                ///< 이름이 정해졌으면 true, 아니면 false

    void initMenus();
    void initWidgets();
    void setFileName(const QString &name);
    bool saveModifiedTable();

private slots:
    void newTimeTable();
    void openTimeTable();
    void saveTimeTable();
    void modified();
    void headerContextMenuRequested(const QPoint &pos);
};

#endif // TIMETABLE_H


3 번째줄: closeEvent() 는 창이 닫힐 때 호출되는 이벤트 함수이다. Q_DECL_OVERRIDE 는 선언된 함수가 실제 오버라이딩 하고 있는지 검사한다. C++11 이상이라면 override 로 대체되고, 아니면 아무 일도 하지 않는다.

27 번째줄: QTableWidgetQTableView 에 대한 편의 클래스이다. 아이템 형식으로 테이블을 조작할 수 있다.

2.3 소스 파일(timetable.cpp)

2.3.1 생성자와 소멸자

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
/**
 * @brief TimeTable::TimeTable 생성자
 * @param parent 부모 위젯
 */

TimeTable::TimeTable(QWidget *parent)
    : QMainWindow(parent)
    , _tableRows(0)
    , _tableCols(0)
    , _timeTable(0)
    , _named(false)
{
    initMenus();    // 메뉴 초기화
    initWidgets();  // 위젯 초기화

    // newTimeTable() 을 나중에 이벤트 루프에서 호출
    QMetaObject::invokeMethod(this"newTimeTable", Qt::QueuedConnection);
}

/**
 * @brief TimeTable::~TimeTable 소멸자
 */

TimeTable::~TimeTable()
{

}


초기화를 진행하고, 새로운 시간표를 만든다.

16 번째줄: QMetaObject::invokeMethod() 는 slot 으로 등록된 함수를 호출한다. Qt::QueuedConnection 은 Qt 에서 제공하는 여러 Connection Type 중의 하나인데, 함수를 직접 호출(Qt::DirectConnection)하는 것이 아니라, 이벤트 큐에 추가한 후, 이벤트 루프에서 이벤트를 처리할 때 함수가 실행되도록 한다. 이 함수는 나중에 멀티 쓰레드 프로그래밍을 할 때도 유용하게 쓰이는 함수이니, 꼭 기억해 두도록 하자.

2.3.2 initMenus()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
/**
 * @brief TimeTable::initMenus 메뉴를 초기화한다
 */

void TimeTable::initMenus()
{
    // "파일" 메뉴 생성
    QMenu *fileMenu = new QMenu(tr("파일(&F)"));
    // "새 시간표" 액션 추가
    fileMenu->addAction(tr("새 시간표(&N)"), this, SLOT(newTimeTable()),
                        QKeySequence(QKeySequence::New));
    // "열기" 액션 추가
    fileMenu->addAction(tr("열기(&O)..."), this, SLOT(openTimeTable()),
                        QKeySequence(QKeySequence::Open));
    // "저장하기" 액션 추가
    fileMenu->addAction(tr("저장하기(&S)"), this, SLOT(saveTimeTable()),
                        QKeySequence(QKeySequence::Save));
    // "구분자" 추가
    fileMenu->addSeparator();
    // "끝내기" 액션 추가
    fileMenu->addAction(tr("끌내기(&x)"), this, SLOT(close()),
                        QKeySequence(tr("Ctrl+Q")));

    // "파일" 메뉴 추가
    menuBar()->addMenu(fileMenu);
}


"파일" 메뉴를 생성하여, 메뉴바에 추가한다.

20 번째줄: 이전과 다르게, "끝내기" 액션을 qApp->quit() 에 연결하지 않고, this->close() 에 연결하였다. 이것은 프로그램이 끝날 때, closeEvent() 에서 변경된 시간표를 저장하기 위한 것이다.

2.3.3 initWidgets()

1
2
3
4
5
6
7
8
9
10
11
12
13
/**
 * @brief TimeTable::initWidgets 위젯을 초기화한다
 */

void TimeTable::initWidgets()
{
    // 테이블 위젯 생성
    _timeTable = new QTableWidget;
    // 내용에 맞게 크기 조절
    _timeTable->setSizeAdjustPolicy(QTableWidget::AdjustToContents);

    // 센트럴 위젯 설정
    setCentralWidget(_timeTable);
}


테이블 위젯을 생성하고 센트를 위젯을 설정한다.

테이블 위젯은 엑셀같은 표를 만들 수 일도록 해준다.

Vista 스타일Mac 스타일Fusion 스타일
출처: Qt 도움말(QTableWidget)

9 번째 줄: QTableWidget::setSizeAdjustPolicy() 는 크기 조절 정책을 설정하는 함수로, QTableWidget::AdjustToContents 는 내용에 맞추어 크기를 조절하도록 한다.

2.3.4 newTimeTable()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
/**
 * @brief TimeTable::newTimeTable 새 시간표를 만든다
 */

void TimeTable::newTimeTable()
{
    static const QStringList weekDays(
                QStringList() << tr("월") << tr("화") << tr("수") << tr("목")
                              << tr("금") << tr("토"));

    // 변경된 시간표를 저장할지 물어봄
    if (!saveModifiedTable())
        return;

    _timeTable->clear();    // 테이블 초기화

    _tableCols = 6;         // 세로줄 수는 6
    _tableRows = 8;         // 가로줄 수는 8

    _timeTable->setColumnCount(_tableCols); // 세로줄 수 설정
    _timeTable->setRowCount(_tableRows);    // 가로줄 수 설정


새 시간표를 만든다.

6 번째 줄: QStringListQList<QString> 과 같다.

14 번째 줄: QTableWidget::clear() 는 모든 내용을 지우고, 아이템을 삭제한다.

19 번째 줄: QTableWidget::setColumnsCount() 는 테이블의 세로줄을 설정한다.

20 번째 줄: QTableWidget::setRowcount() 는 테이블의 가로줄을 설정한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
    // 가로 헤더 아이템 설정
    for (int i = 0; i < _tableCols; ++i)
    {
        // 테이블 아이템 생성
        QTableWidgetItem *item = new QTableWidgetItem;
        // 아이템 텍스트 요일로 설정
        item->setText(weekDays.at(i));
        // 가로 헤더 아이템 설정
        _timeTable->setHorizontalHeaderItem(i, item);
    }
    // 가로 헤더 컨텍스트 메뉴 정책 설
    _timeTable->horizontalHeader()->setContextMenuPolicy(Qt::CustomContextMenu);
    // 컨텍스트 메뉴 시그널 연결
    connect(_timeTable->horizontalHeader(),
            SIGNAL(customContextMenuRequested(QPoint)),
            this, SLOT(headerContextMenuRequested(QPoint)));


5 번째 줄: QTableWidgetItemQTableWidget 의 헤더나 셀에 쓰인다.

7 번째 줄: QTableWidgetItem::setText() 는 아이템의 텍스트를 설정한다.

9 번째 줄: QTableWidget::setHorizontalHeaderItem() 은 해당 위치에 아이템을 설정한다.

12 번째 줄: QHeaderView::setContextMenuPolicy() 는 컨텍스트 메뉴를 어떻게 처리할지를 결정한다. Qt::CustomContextMenu 는 컨텍스트 메뉴를 사용자가 처리하도록 한다.

15 번째 줄: customContextMenuRequested() 시그널은 사용자가 오른쪽 마우스 버튼을 눌러 컨텍스트 메뉴를 호출했을 때 보내진다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
    // 세로 헤더 아이템 설정
    for (int i = 0; i < _tableRows; ++i)
    {
        // 테이블 아이템 생성
        QTableWidgetItem *item = new QTableWidgetItem;
        // 아이템 텍스트 시간으로 설정
        item->setText(QString::number(i + 1));
        // 세로 헤더 아이템 설정
        _timeTable->setVerticalHeaderItem(i, item);
    }
    // 세로 헤더 컨텍스트 메뉴 정책 설정
    _timeTable->verticalHeader()->setContextMenuPolicy(Qt::CustomContextMenu);
    // 컨텍스트 메뉴 시그널 연결
    connect(_timeTable->verticalHeader(),
            SIGNAL(customContextMenuRequested(QPoint)),
            this, SLOT(headerContextMenuRequested(QPoint)));


세로 헤더에 대한 설정 부분이다.

1
2
3
4
5
6
7
8
9
    // 테이블 셀 초기화
    for (int i = 0; i < _tableRows; ++i)
    {
        for (int j = 0; j < _tableCols; ++j)
        {
            // 빈 아이템으로 설정
            _timeTable->setItem(i, j, new QTableWidgetItem(QString()));
        }
    }


7 번째 줄: QTableWidget::setItem() 은 주어진 위치에 아이템을 설정한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
    // 테이블 크기에 맞추어서 창 크기 조절
    resize(_timeTable->sizeHint().width(),
           _timeTable->sizeHint().height());

    // 내부 위젯에 맞추어서 크기 조절
    adjustSize();

    // 셀 내용이 바뀌면 modified() 호출
    connect(_timeTable, SIGNAL(cellChanged(int,int)), this, SLOT(modified()));

    setFileName(tr("이름 없음"));   // 새 이름은 "이름 없음"
    setWindowModified(false);       // 변경되지 않았음
}


2 번째 줄: QWidget::resize() 는 창의 폭과 높이를 바꾼다.

6 번째 줄: QWidget::adjustSize() 내부에 포함하고 있는 모든 자식 위젯을 보이도록 창 크기를 조절한다.

9 번째 줄: cellChanged() 시그널은 테이블의 내용이 바뀌었을 때 발생한다.

12 번째 줄: setWindowModified() 는 창의 내용이 변경되었는지를 설정한다. 변경 상태에 따라 창 제목에 * 가 표시된다.

2.3.5 openTimeTable()

1
2
3
4
5
6
7
8
9
10
11
12
13
/**
 * @brief TimeTable::openTimeTable 시간표 파일을 읽는다
 */

void TimeTable::openTimeTable()
{
    // 변경된 시간표를 저장할지 물어봄
    if (!saveModifiedTable())
        return;

    QString name = QFileDialog::getOpenFileName(this, tr("시간표 열기"),
                                                QString(),
                                                tr("시간표 (*.tbl);;"
                                                   "모든 파일 (*)"));


파일로 저장된 시간표를 읽는다.

10 번째 줄: QFileDialog::getOpenFileName() 은 읽을 파일을 고르는 정적함수이다. 네 번째 인자는 필터를 지정하는데, 여러 필터를 지정할 때는 ;; 를 구분자로 쓴다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
    // 파일을 골랐으면
    if (!name.isEmpty())
    {
        QFile f(name);

        // 읽기 전용으로 파일 열기
        if (f.open(QIODevice::ReadOnly))
        {
            _timeTable->clear();    // 테이블 초기화


            QTextStream in(&f);     // 파일을 텍스트 스트림으로 처리

            in >> _tableRows >> _tableCols; // 세로줄 수와 가로줄 수 읽기
            in.readLine();                  // 줄바꿈 문자 읽기


4 번째 줄: QFile 은 Qt 에서 파일 제어를 위해 제공하는 클래스이다.

7 번째 줄: QFile::open() 은 파일을 주어진 모드로 연다. QIODevice::ReadOnly 는 읽기 전용이다.

12 번째 줄: QTextStream 은 주어진 장치를 텍스트 스트림으로 조작한다. 텍스트 스트림은 DOS, 윈도우, OS/2 같은 DOS 기반 운영체제에서, 저장할 때는 \n 을 \r\n 으로, 읽을 때는 \n 을 \r\n 을 \n(2016/09/27)으로 바꾼다. 그리고 operator>>() 를 통해 값을 읽을 수 있다.

15 번째 줄: QTextStream::readLine() 을 한 줄을 읽는다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
            _timeTable->setColumnCount(_tableCols); // 가로줄 수 설정
            _timeTable->setRowCount(_tableRows);    // 세로줄 수 설정

            // 세로 헤더 아이템 읽어 설정
            for (int i = 0; i < _tableCols; ++i)
            {
                QTableWidgetItem *item = new QTableWidgetItem;
                item->setText(in.readLine());
                _timeTable->setHorizontalHeaderItem(i, item);
            }
            _timeTable->horizontalHeader()->setContextMenuPolicy(Qt::CustomContextMenu);
            connect(_timeTable->horizontalHeader(),
                    SIGNAL(customContextMenuRequested(QPoint)),
                    this, SLOT(headerContextMenuRequested(QPoint)));

            // 가로 헤더 아이템 읽어 설정
            for (int i = 0; i < _tableRows; ++i)
            {
                QTableWidgetItem *item = new QTableWidgetItem;
                item->setText(in.readLine());
                _timeTable->setVerticalHeaderItem(i, item);
            }
            _timeTable->verticalHeader()->setContextMenuPolicy(Qt::CustomContextMenu);
            connect(_timeTable->verticalHeader(),
                    SIGNAL(customContextMenuRequested(QPoint)),
                    this, SLOT(headerContextMenuRequested(QPoint)));

            // 테이블 셀 설정
            for (int i = 0; i < _tableRows; ++i)
            {
                for (int j = 0; j < _tableCols; ++j)
                {
                    _timeTable->setItem(i, j,
                                        new QTableWidgetItem(in.readLine()));
                }
            }

            f.close();  // 파일 닫음


newTimeTable() 에서처럼 테이블의 헤더와 셀을 설정한다. 다만, 내용은 파일에서 읽어온다.

38 번째 줄: QFile::close() 는 열린 파일을 닫는다.

1
2
3
4
5
6
7
8
9
10
11
12
13
             // 테이블 크기에 따라 창크기 조절
            resize(_timeTable->sizeHint().width(),
                   _timeTable->sizeHint().height());


            adjustSize();   // 내부 위젯에 맞추어 크기 조절


            setFileName(name);          // 파일 이름 설정
            setWindowModified(false);   // 변경되지 않았
        }
    }
}


크기를 조절하고, 파일 이름과 변경 상태를 설정한다.

2.3.6 saveTimeTable()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
/**
 * @brief TimeTable::saveTimeTable 시간표를 저장한다
 */

void TimeTable::saveTimeTable()
{
    // 파일이름이 정해지지 않았거나 내용이 변경되었으면
    if (!_named || isWindowModified())
    {
        QString name;

        // 정해진 이름이 있으면 그 이름을 쓰고, 아니면 파일 이름을 물어봄
        name = _named ? _fileName :
                        QFileDialog::getSaveFileName(this, tr("시간표 저장"),
                                                     QString(),
                                                     tr("시간표 (*.tbl);;"
                                                        "모든 파일 (*)"));


13 번째 줄: QFileDialog::getSaveFileName() 은 저장할 파일 이름을 고르는 정작함수이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
        if (!name.isEmpty())
        {
            QFile f(name);

            // 쓰기 모드로 파일 열기
            if (f.open(QIODevice::WriteOnly))
            {
                QTextStream out(&f);    // 텍스트 스트림으로 처리


                // 세로줄 수 및 가로줄 수 저장
                out << _tableRows << " " << _tableCols << "\n";

                // 가로 헤더 아이템 저장
                for (int i = 0; i < _tableCols; ++i)
                    out << _timeTable->horizontalHeaderItem(i)->text() << "\n";

                // 세로 헤더 아이템 저장
                for (int i =  0; i < _tableRows; ++i)
                    out << _timeTable->verticalHeaderItem(i)->text() << "\n";

                // 테이블 셀 저장
                for (int i = 0; i < _tableRows; ++i)
                {
                    for (int j = 0; j < _tableCols; ++j)
                        out << _timeTable->item(i, j)->text() << "\n";
                }

                f.close();  // 파일 닫기


6 번째 줄: QIODevice::WriteOnly 는 쓰기 전용을 나타내는 플래그이다.

12 번째 줄: QTextStreamoperator<<() 로 쓰기 작업을 수행할 수 있다.

1
2
3
4
5
6
7
8
                setFileName(name);          // 파일 이름 설정
                setWindowModified(false);   // 변경되지 않았음

                _named = true;  // 이름 정해졌음
            }
        }
    }
}


파일 이름을 설정하고, 변경상태를 초기화한다.

2.3.7 setFileName()

1
2
3
4
5
6
7
8
9
10
11
12
13
/**
 * @brief TimeTable::setFileName 현재 파일이름을 설정한다
 * @param name 설정할 파일 이름
 */

void TimeTable::setFileName(const QString &name)
{
    _fileName = name;

    // 창 제목을 "파일 이름 - 프로그램이름" 형태로 표시
    // [*] 는 내용이 바뀌면 * 를 표시
    setWindowTitle(QFileInfo(_fileName).fileName() + " - " +
                   qApp->applicationDisplayName() + "[*]");
}


파일 이름을 설정하고, 창 제목을 바꾼다. [*] 는 창 내용이 수정되었을 때(setWindowModified(true)) * 로 대체된다.

11 번째 줄: QFileInfo::fileName() 은 주어진 파일 경로 중에서 파일 이름을 돌려준다.

12 번째 줄: QGuiApplication::applicationDisplayName() 은 프로그램의 이름을 돌려준다. 보통 실행 파일 이름이다.

2.3.8 modified()

1
2
3
4
5
6
7
/**
 * @brief TimeTable::modified 시간표 내용이 변경되면 변경 상태를 설정한다
 */

void TimeTable::modified()
{
    setWindowModified(true);    // 변경되었음
}


시간표가 바뀌었을 때 호출되고, 창의 변경 상태를 설정한다.

2.3.9 saveModifiedTable()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
/**
 * @brief TimeTable::saveModifiedTable 변경된 시간표를 저장한다
 * @return Yes 또는 No 이면 true, Cancel 이면 false
 */

bool TimeTable::saveModifiedTable()
{
    // 변경되었으면
    if (isWindowModified())
    {
        // 저장할지 물어봄
        switch(QMessageBox::information(this, qApp->applicationDisplayName(),
                                        tr("시간표가 변경되었습니다. "
                                           "저장할까요?"),
                                        QMessageBox::Yes | QMessageBox::No |
                                        QMessageBox::Cancel))
        {
        case QMessageBox::Cancel:   // 취소
            return false;

        case QMessageBox::Yes:      // 저장
            saveTimeTable();
            break;

        case QMessageBox::No:       // 저장 안 함
        default:                    // 경고 제거용
            break;
        }
    }

    return true;
}


시간표가 변경되었으면 물어보고 저장한다.

8 번째 줄: QWidget::isWindowModified() 는 창의 변경 상태를 알려준다.

11 번째 줄: QMessageBox::information() 은 사용자에게 정보를 보여주는 정적함수이다.

2.3.10 headerContextMenuRequested()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
/**
 * @brief TimeTable::headerContextMenuRequested 헤더 컨텍스트 메뉴를 처리한다
 * @param pos 컨텍스트 메뉴가 호출된 위치
 */

void TimeTable::headerContextMenuRequested(const QPoint &pos)
{
    // 시그널을 보낸 헤더 뷰 위젯
    QHeaderView *headerView = qobject_cast<QHeaderView *>(sender());

    // 컨텍스트 메뉴가 호출된 위치에 있는 아이템의 인덱스
    int index = headerView->logicalIndexAt(pos);
    // 테이블 위젯 아이템 얻기
    QTableWidgetItem *item = headerView->orientation() == Qt::Horizontal
            ? _timeTable->horizontalHeaderItem(index)
            : _timeTable->verticalHeaderItem(index);

    // 새 이름을 물어봄
    QString newName =  QInputDialog::getText(this, qApp->applicationName(),
                                             tr("새 이름"), QLineEdit::Normal,
                                             item->text());

    if (!newName.isEmpty())
    {
        item->setText(newName); // 아이템 텍스트 설정

        // 테이블 크기에 따라 창 크기 조절
        resize(_timeTable->sizeHint().width(),
               _timeTable->sizeHint().height());

        adjustSize();   // 내부 위젯에 맞추어 창 크기 조절

        modified(); // 변경되었음
    }
}


11 번째: QHeaderView::logicalIndexAt() 은 주어진 위치에 해당하는 헤더의 인덱스를 돌려준다.

13 번째: QHeaderView::orientation() 은 헤더의 방향을 돌려준다.

14~15 번째: QTableWidget::horizontalHeaderItem()QTableWidget::verticalHeaderItem() 은 주어진 인덱스에 해당하는 아이템을 돌려준다.

18 번째: QInputDialog::getText() 는 사용자로부터 문자열을 입력받는 정적함수이다.

2.3.11 closeEvent()

1
2
3
4
5
6
7
8
9
10
11
/**
 * @brief TimeTable::closeEvent closeEvent() 를 처리한다
 * @param e 이벤트
 */

void TimeTable::closeEvent(QCloseEvent *e)
{
    if (saveModifiedTable())
        e->accept();
    else
        e->ignore();
}


closeEvent() 를 처리한다.

8 번째줄: accept() 하면 이후 과정이 이어지고, ignore() 하면 중단된다.

3. 마무리하면서...

<시간표> 를 만들어보면서 파일 입출력과 테이블 위젯에 대해 간단히 살펴보았다. 보다 확장성 있게 만들어 보면 더욱 좋을 것이다. 배경색을 다르게 하는 것 같은... 그리고 출력 기능도 추가하면 보다 완벽한 시간표 프로그램이 될 수 있을 것이다. 많은 아쉬움이 있지만, 다음으로 미루도록 하자. ^^

언제나 말하지만, Qt 도움말과 Assistant  는 꼭 챙기자. 위에 설명된 것은 극히 일부일 뿐이니까.

그리고 윈도용 Qt 5.5 의 QTableWidget 은 현재 버그가 하나 있다. 한글 입력 상태에서 셀에 입력을 하면 첫글자가 영어 알파벳으로 나온다. <시간표> 를 만드는 도중에 확인을 하여, 버그 보고하였고, 실제 버그로 확인이 되었다. 이후 버전에서는 수정될 것으로 기대한다.

다음은 <시간표> 의 실행모습이다.


전체 소스는 여기에서 확인하자.


댓글

이 블로그의 인기 게시물

토렌트: < 왕좌의 게임 > 시즌 1 ~ 시즌 8 완결편 마그넷

토렌트: NGC < 코스모스 > 우리말 더빙 전편(1편~13편) 마그넷

토렌트: < 스타워즈 > Ep.1 ~ Ep.6 마그넷